feat: better interrupt strategies (#479)

* chore: drain active calls before rolling updates

* Use provisional VAD interruption strategy

* feat: wire provisional VAD configuration

* chore: refactor user turn strategies

* chore: bump pipecat
This commit is contained in:
Abhishek 2026-06-30 14:52:17 +05:30 committed by GitHub
parent 962d5afa12
commit 6937e01b49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 645 additions and 193 deletions

3
.vscode/launch.json vendored
View file

@ -22,8 +22,7 @@
"args": [
"api.app:app",
"--reload",
"--host", "0.0.0.0",
"--port", "8000"
"--host", "0.0.0.0"
],
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/api/.env",

View file

@ -80,7 +80,8 @@ from pipecat.turns.user_mute import (
)
from pipecat.turns.user_start import (
ExternalUserTurnStartStrategy,
TranscriptionUserTurnStartStrategy,
MinWordsUserTurnStartStrategy,
ProvisionalVADUserTurnStartStrategy,
)
from pipecat.turns.user_start.vad_user_turn_start_strategy import (
VADUserTurnStartStrategy,
@ -99,6 +100,10 @@ ensure_tracing()
DEFAULT_USER_TURN_STOP_TIMEOUT = 5.0
EXTERNAL_TURN_USER_STOP_TIMEOUT = 30.0
DEFAULT_TURN_START_STRATEGY = "default"
DEFAULT_TURN_START_MIN_WORDS = 3
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS = 1.5
DEFAULT_SMART_TURN_STOP_SECS = 2.0
def _resolve_user_turn_stop_timeout(
@ -111,6 +116,80 @@ def _resolve_user_turn_stop_timeout(
return DEFAULT_USER_TURN_STOP_TIMEOUT
def _resolve_turn_start_min_words(run_configs: dict) -> int:
return max(
1,
int(run_configs.get("turn_start_min_words", DEFAULT_TURN_START_MIN_WORDS)),
)
def _resolve_provisional_vad_pause_secs(run_configs: dict) -> float:
return max(
0.1,
float(
run_configs.get(
"provisional_vad_pause_secs", DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
)
),
)
def _create_non_realtime_user_turn_start_strategies(
run_configs: dict, *, uses_external_turns: bool
):
"""Return user turn start strategies for non-realtime pipelines."""
turn_start_strategy = run_configs.get(
"turn_start_strategy", DEFAULT_TURN_START_STRATEGY
)
if turn_start_strategy == "min_words":
return [
MinWordsUserTurnStartStrategy(
min_words=_resolve_turn_start_min_words(run_configs)
)
]
if turn_start_strategy == "provisional_vad":
return [
ProvisionalVADUserTurnStartStrategy(
pause_secs=_resolve_provisional_vad_pause_secs(run_configs)
)
]
if uses_external_turns:
# The STT emits its own turn boundaries and owns interruptions. Local
# VAD is deliberately kept out of the default start strategies: it would
# win the race on raw voice activity and start the turn before the STT
# confirms a real turn.
return [ExternalUserTurnStartStrategy(enable_interruptions=True)]
return [VADUserTurnStartStrategy()]
def _create_non_realtime_user_turn_stop_strategies(
run_configs: dict, *, uses_external_turns: bool
):
"""Return user turn stop strategies for non-realtime pipelines."""
if uses_external_turns:
return [ExternalUserTurnStopStrategy()]
if run_configs.get("turn_stop_strategy") == "turn_analyzer":
smart_turn_params = SmartTurnParams(
stop_secs=run_configs.get(
"smart_turn_stop_secs", DEFAULT_SMART_TURN_STOP_SECS
)
)
return [
TurnAnalyzerUserTurnStopStrategy(
turn_analyzer=LocalSmartTurnAnalyzerV3(params=smart_turn_params)
)
]
return [SpeechTimeoutUserTurnStopStrategy()]
def _create_realtime_user_turn_config(provider: str):
"""Return user turn strategies and optional local VAD for realtime providers."""
@ -461,8 +540,6 @@ async def _run_pipeline_impl(
# Extract configurations from the version's workflow_configurations
max_call_duration_seconds = 300 # Default 5 minutes
max_user_idle_timeout = 10.0 # Default 10 seconds
smart_turn_stop_secs = 2.0 # Default 2 seconds for incomplete turn timeout
turn_stop_strategy = "transcription" # Default to transcription-based detection
keyterms = None # Dictionary words for STT boosting
if run_configs:
@ -472,12 +549,6 @@ async def _run_pipeline_impl(
if "max_user_idle_timeout" in run_configs:
max_user_idle_timeout = run_configs["max_user_idle_timeout"]
if "smart_turn_stop_secs" in run_configs:
smart_turn_stop_secs = run_configs["smart_turn_stop_secs"]
if "turn_stop_strategy" in run_configs:
turn_stop_strategy = run_configs["turn_stop_strategy"]
if "dictionary" in run_configs:
dictionary = run_configs["dictionary"]
if dictionary and isinstance(dictionary, str):
@ -734,37 +805,27 @@ async def _run_pipeline_impl(
# follows those external signals. Other models use configurable turn
# detection.
uses_external_turns = stt_uses_external_turns(user_config)
if uses_external_turns:
user_turn_strategies = UserTurnStrategies(
start=[
VADUserTurnStartStrategy(),
ExternalUserTurnStartStrategy(enable_interruptions=True),
],
stop=[ExternalUserTurnStopStrategy()],
)
elif turn_stop_strategy == "turn_analyzer":
# Smart Turn Analyzer: best for longer responses with natural pauses
smart_turn_params = SmartTurnParams(stop_secs=smart_turn_stop_secs)
user_turn_strategies = UserTurnStrategies(
start=[
VADUserTurnStartStrategy(),
TranscriptionUserTurnStartStrategy(),
],
stop=[
TurnAnalyzerUserTurnStopStrategy(
turn_analyzer=LocalSmartTurnAnalyzerV3(params=smart_turn_params)
)
],
)
else:
# Transcription-based (default): best for short 1-2 word responses
user_turn_strategies = UserTurnStrategies(
start=[
VADUserTurnStartStrategy(),
TranscriptionUserTurnStartStrategy(),
],
stop=[SpeechTimeoutUserTurnStopStrategy()],
)
user_turn_start_strategies = _create_non_realtime_user_turn_start_strategies(
run_configs,
uses_external_turns=uses_external_turns,
)
turn_start_strategy = run_configs.get(
"turn_start_strategy", DEFAULT_TURN_START_STRATEGY
)
logger.info(
f"[run {workflow_run_id}] Non-realtime interrupt strategy "
f"requested={turn_start_strategy} "
f"uses_external_turns={uses_external_turns}"
)
user_turn_stop_strategies = _create_non_realtime_user_turn_stop_strategies(
run_configs,
uses_external_turns=uses_external_turns,
)
user_turn_strategies = UserTurnStrategies(
start=user_turn_start_strategies,
stop=user_turn_stop_strategies,
)
user_turn_stop_timeout = _resolve_user_turn_stop_timeout(
run_configs,

View file

@ -197,6 +197,7 @@ def create_stt_service(
return OpenAISTTService(
api_key=user_config.stt.api_key,
settings=OpenAISTTSettings(model=user_config.stt.model),
should_interrupt=False, # Let UserAggregator own interruption confirmation.
**kwargs,
)
elif user_config.stt.provider == ServiceProviders.GOOGLE.value:

View file

@ -1,6 +1,8 @@
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.turns.user_start import (
ExternalUserTurnStartStrategy,
MinWordsUserTurnStartStrategy,
ProvisionalVADUserTurnStartStrategy,
)
from pipecat.turns.user_start.vad_user_turn_start_strategy import (
VADUserTurnStartStrategy,
@ -8,12 +10,18 @@ from pipecat.turns.user_start.vad_user_turn_start_strategy import (
from pipecat.turns.user_stop import (
ExternalUserTurnStopStrategy,
SpeechTimeoutUserTurnStopStrategy,
TurnAnalyzerUserTurnStopStrategy,
)
import api.services.pipecat.run_pipeline as run_pipeline_module
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.run_pipeline import (
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
DEFAULT_USER_TURN_STOP_TIMEOUT,
EXTERNAL_TURN_USER_STOP_TIMEOUT,
_create_non_realtime_user_turn_start_strategies,
_create_non_realtime_user_turn_stop_strategies,
_create_realtime_user_turn_config,
_resolve_user_turn_stop_timeout,
)
@ -115,6 +123,119 @@ def test_unknown_realtime_providers_keep_local_vad():
assert strategies.stop[0].wait_for_transcript is False
def test_non_realtime_default_uses_external_start_for_external_turn_stt():
strategies = _create_non_realtime_user_turn_start_strategies(
{},
uses_external_turns=True,
)
assert len(strategies) == 1
assert isinstance(strategies[0], ExternalUserTurnStartStrategy)
assert strategies[0]._enable_interruptions is True
def test_non_realtime_default_uses_vad_start_for_standard_stt():
strategies = _create_non_realtime_user_turn_start_strategies(
{},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], VADUserTurnStartStrategy)
def test_non_realtime_can_use_min_words_start_strategy():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "min_words", "turn_start_min_words": 4},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], MinWordsUserTurnStartStrategy)
assert strategies[0]._min_words == 4
def test_non_realtime_explicit_min_words_overrides_external_turn_default():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "min_words", "turn_start_min_words": 4},
uses_external_turns=True,
)
assert len(strategies) == 1
assert isinstance(strategies[0], MinWordsUserTurnStartStrategy)
assert strategies[0]._min_words == 4
def test_non_realtime_min_words_start_strategy_has_default_threshold():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "min_words"},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], MinWordsUserTurnStartStrategy)
assert strategies[0]._min_words == DEFAULT_TURN_START_MIN_WORDS
def test_non_realtime_can_use_provisional_vad_start_strategy():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "provisional_vad"},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], ProvisionalVADUserTurnStartStrategy)
assert strategies[0]._pause_secs == DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
def test_non_realtime_provisional_vad_uses_configured_pause_secs():
strategies = _create_non_realtime_user_turn_start_strategies(
{"turn_start_strategy": "provisional_vad", "provisional_vad_pause_secs": 0.4},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], ProvisionalVADUserTurnStartStrategy)
assert strategies[0]._pause_secs == 0.4
def test_non_realtime_uses_external_stop_for_external_turn_stt():
strategies = _create_non_realtime_user_turn_stop_strategies(
{},
uses_external_turns=True,
)
assert len(strategies) == 1
assert isinstance(strategies[0], ExternalUserTurnStopStrategy)
def test_non_realtime_default_uses_speech_timeout_stop():
strategies = _create_non_realtime_user_turn_stop_strategies(
{},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], SpeechTimeoutUserTurnStopStrategy)
def test_non_realtime_can_use_turn_analyzer_stop_strategy(monkeypatch):
monkeypatch.setattr(
run_pipeline_module,
"LocalSmartTurnAnalyzerV3",
lambda *, params: params,
)
strategies = _create_non_realtime_user_turn_stop_strategies(
{"turn_stop_strategy": "turn_analyzer", "smart_turn_stop_secs": 1.5},
uses_external_turns=False,
)
assert len(strategies) == 1
assert isinstance(strategies[0], TurnAnalyzerUserTurnStopStrategy)
assert strategies[0]._turn_analyzer.stop_secs == 1.5
def test_external_turn_stt_uses_longer_stop_timeout():
assert (
_resolve_user_turn_stop_timeout({}, uses_external_turns=True)

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit 12af7a65c576ce52225c735917e44075d202ab1a
Subproject commit 63f0bc437ebe50ae4616b1cc2d69667c4ae3dc58

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.IQaxuC56sd
# timestamp: 2026-06-29T10:55:38+00:00
# filename: dograh-openapi-XXXXXX.json.mFeCVL0pIi
# timestamp: 2026-06-30T08:46:04+00:00
from __future__ import annotations

172
ui/package-lock.json generated
View file

@ -717,6 +717,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -918,7 +919,6 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -1062,7 +1062,6 @@
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
@ -1081,15 +1080,13 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1099,7 +1096,6 @@
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
@ -1112,22 +1108,19 @@
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@emotion/react": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1152,7 +1145,6 @@
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
@ -1165,22 +1157,19 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": ">=16.8.0"
}
@ -1189,15 +1178,13 @@
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
@ -2485,7 +2472,6 @@
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@ -2724,6 +2710,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@ -2774,6 +2761,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz",
"integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.57.2",
"@types/shimmer": "^1.2.0",
@ -3445,6 +3433,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=14"
}
@ -8167,6 +8156,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -10050,6 +10040,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@ -10461,7 +10452,6 @@
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -10472,7 +10462,6 @@
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
@ -10525,8 +10514,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.6.1",
@ -10553,6 +10541,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -10563,6 +10552,7 @@
"integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -10572,7 +10562,6 @@
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "*"
}
@ -11102,7 +11091,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
@ -11112,29 +11100,25 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -11145,15 +11129,13 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11166,7 +11148,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
@ -11176,7 +11157,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@xtuc/long": "4.2.2"
}
@ -11185,15 +11165,13 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11210,7 +11188,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
@ -11224,7 +11201,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11237,7 +11213,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -11252,7 +11227,6 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2"
@ -11268,15 +11242,13 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
@ -11343,6 +11315,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -11364,7 +11337,6 @@
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
},
@ -11416,7 +11388,6 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@ -11434,7 +11405,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -11450,8 +11420,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/ansi-colors": {
"version": "4.1.3",
@ -11763,7 +11732,6 @@
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
@ -11891,6 +11859,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -12095,7 +12064,6 @@
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0"
}
@ -12354,7 +12322,6 @@
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
@ -12526,6 +12493,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -12868,7 +12836,6 @@
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
@ -12965,7 +12932,6 @@
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.2.1"
}
@ -12974,8 +12940,7 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.23.9",
@ -13095,8 +13060,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@ -13248,6 +13212,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -13421,6 +13386,7 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -13708,7 +13674,6 @@
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
@ -13814,8 +13779,7 @@
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
@ -13897,8 +13861,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
@ -14150,8 +14113,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/globals": {
"version": "14.0.0",
@ -14365,6 +14327,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -14974,7 +14937,6 @@
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@ -14989,7 +14951,6 @@
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -15079,8 +15040,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
@ -15412,15 +15372,13 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/loader-runner": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.11.5"
},
@ -15501,15 +15459,13 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
@ -15540,7 +15496,6 @@
"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"
}
@ -15550,7 +15505,6 @@
"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"
},
@ -15648,14 +15602,14 @@
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/next": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.14",
"@swc/helpers": "0.5.15",
@ -16083,7 +16037,6 @@
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@ -16158,7 +16111,6 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -16601,6 +16553,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -16631,6 +16584,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -16657,6 +16611,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -16681,13 +16636,15 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -16827,7 +16784,6 @@
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
@ -16893,7 +16849,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -16931,8 +16888,7 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -16969,7 +16925,6 @@
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -17054,6 +17009,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -17225,7 +17181,6 @@
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
@ -17262,7 +17217,6 @@
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@ -17274,8 +17228,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.0.0",
@ -17831,8 +17784,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
@ -17872,7 +17824,8 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz",
"integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -17901,7 +17854,6 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -17920,7 +17872,6 @@
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
@ -17953,8 +17904,7 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "3.1.0",
@ -18031,6 +17981,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -18219,6 +18170,7 @@
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -18405,7 +18357,6 @@
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
@ -18492,7 +18443,6 @@
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
"integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
"license": "MIT",
"peer": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@ -18518,7 +18468,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -18582,7 +18531,6 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@ -18596,7 +18544,6 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@ -18774,7 +18721,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">= 6"
}
@ -18883,6 +18829,7 @@
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
@ -18925,6 +18872,7 @@
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
},

View file

@ -6,7 +6,15 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { AmbientNoiseConfiguration, TurnStopStrategy, WorkflowConfigurations } from "@/types/workflow-configurations";
import {
AmbientNoiseConfiguration,
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
TURN_START_STRATEGY_OPTIONS,
TurnStartStrategy,
TurnStopStrategy,
WorkflowConfigurations,
} from "@/types/workflow-configurations";
interface ConfigurationsDialogProps {
open: boolean;
@ -41,6 +49,15 @@ export const ConfigurationsDialog = ({
const [smartTurnStopSecs, setSmartTurnStopSecs] = useState<number>(
workflowConfigurations?.smart_turn_stop_secs || 2 // Default 2 seconds
);
const [turnStartStrategy, setTurnStartStrategy] = useState<TurnStartStrategy>(
workflowConfigurations?.turn_start_strategy || 'default'
);
const [turnStartMinWords, setTurnStartMinWords] = useState<number>(
workflowConfigurations?.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS
);
const [provisionalVadPauseSecs, setProvisionalVadPauseSecs] = useState<number>(
workflowConfigurations?.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
);
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
workflowConfigurations?.turn_stop_strategy || 'transcription'
);
@ -48,6 +65,9 @@ export const ConfigurationsDialog = ({
workflowConfigurations?.context_compaction_enabled ?? false
);
const [isSaving, setIsSaving] = useState(false);
const selectedTurnStartStrategy = TURN_START_STRATEGY_OPTIONS.find(
(option) => option.value === turnStartStrategy
);
const handleSave = async () => {
setIsSaving(true);
@ -57,6 +77,9 @@ export const ConfigurationsDialog = ({
max_call_duration: maxCallDuration,
max_user_idle_timeout: maxUserIdleTimeout,
smart_turn_stop_secs: smartTurnStopSecs,
turn_start_strategy: turnStartStrategy,
turn_start_min_words: turnStartMinWords,
provisional_vad_pause_secs: provisionalVadPauseSecs,
turn_stop_strategy: turnStopStrategy,
context_compaction_enabled: contextCompactionEnabled,
}, name);
@ -76,6 +99,9 @@ export const ConfigurationsDialog = ({
setMaxCallDuration(workflowConfigurations?.max_call_duration || 600);
setMaxUserIdleTimeout(workflowConfigurations?.max_user_idle_timeout || 10);
setSmartTurnStopSecs(workflowConfigurations?.smart_turn_stop_secs || 2);
setTurnStartStrategy(workflowConfigurations?.turn_start_strategy || 'default');
setTurnStartMinWords(workflowConfigurations?.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS);
setProvisionalVadPauseSecs(workflowConfigurations?.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS);
setTurnStopStrategy(workflowConfigurations?.turn_stop_strategy || 'transcription');
setContextCompactionEnabled(workflowConfigurations?.context_compaction_enabled ?? false);
}
@ -218,6 +244,80 @@ export const ConfigurationsDialog = ({
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="turn_start_strategy" className="text-xs">
Interruption Strategy
</Label>
<Select
value={turnStartStrategy}
onValueChange={(value: TurnStartStrategy) => setTurnStartStrategy(value)}
>
<SelectTrigger id="turn_start_strategy">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
{TURN_START_STRATEGY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{selectedTurnStartStrategy?.description}
</p>
</div>
{turnStartStrategy === 'min_words' && (
<div className="space-y-2">
<Label htmlFor="turn_start_min_words" className="text-xs">
Minimum Words Before Interruption
</Label>
<Input
id="turn_start_min_words"
type="number"
step="1"
min="1"
max="10"
value={turnStartMinWords}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= 1) {
setTurnStartMinWords(value);
}
}}
/>
<p className="text-xs text-muted-foreground">
Number of transcribed words needed to interrupt while the bot is speaking. Default: {DEFAULT_TURN_START_MIN_WORDS}
</p>
</div>
)}
{turnStartStrategy === 'provisional_vad' && (
<div className="space-y-2">
<Label htmlFor="provisional_vad_pause_secs" className="text-xs">
Provisional Pause (seconds)
</Label>
<Input
id="provisional_vad_pause_secs"
type="number"
step="0.1"
min="0.1"
max="5"
value={provisionalVadPauseSecs}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0.1) {
setProvisionalVadPauseSecs(value);
}
}}
/>
<p className="text-xs text-muted-foreground">
Seconds to pause bot audio while waiting for transcript confirmation. Default: {DEFAULT_PROVISIONAL_VAD_PAUSE_SECS}
</p>
</div>
)}
</div>
{/* Context Management Section */}
@ -306,4 +406,3 @@ export const ConfigurationsDialog = ({
</Dialog>
);
};

View file

@ -52,6 +52,7 @@ interface WorkflowRunResponse {
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
const WAVEFORM_BAR_COUNT = 96;
type SplitTrackPlaybackMode = 'both' | 'user' | 'bot';
function formatDuration(seconds?: number | null) {
if (seconds == null || Number.isNaN(seconds)) return 'N/A';
@ -124,19 +125,38 @@ async function loadWaveformPeaks(url: string) {
}
}
function getAudioDuration(audio: HTMLAudioElement | null) {
return audio && Number.isFinite(audio.duration) ? audio.duration : 0;
}
function getAudioTimelineState(audios: HTMLAudioElement[]) {
const duration = Math.max(0, ...audios.map((audio) => getAudioDuration(audio)));
const currentTime = Math.max(0, ...audios.map((audio) => audio.currentTime));
return { duration, currentTime };
}
function syncAudioCurrentTime(audio: HTMLAudioElement, startTime: number) {
const duration = getAudioDuration(audio);
audio.currentTime = Math.min(startTime, duration || startTime);
}
function WaveformLane({
peaks,
track,
position,
isActive,
}: {
peaks: number[] | null;
track: 'user' | 'bot';
position: 'top' | 'bottom';
isActive: boolean;
}) {
return (
<div
className={cn(
'absolute left-3 right-3 flex gap-0.5',
isActive ? 'opacity-85' : 'opacity-25',
position === 'top' ? 'top-5 h-12 items-end' : 'bottom-5 h-12 items-start'
)}
>
@ -145,7 +165,7 @@ function WaveformLane({
<span
key={`${track}-${index}`}
className={cn(
'min-h-1 flex-1 rounded-full opacity-85',
'min-h-1 flex-1 rounded-full',
track === 'user' ? 'bg-sky-500' : 'bg-emerald-500'
)}
style={{ height: `${Math.round(peak * 100)}%` }}
@ -178,6 +198,21 @@ function SplitTracksSection({
const [isLoading, setIsLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [playbackMode, setPlaybackMode] = useState<SplitTrackPlaybackMode>('both');
const getPlaybackAudios = (mode: SplitTrackPlaybackMode) => {
const audios: HTMLAudioElement[] = [];
if (mode !== 'bot' && userAudioRef.current) {
audios.push(userAudioRef.current);
}
if (mode !== 'user' && botAudioRef.current) {
audios.push(botAudioRef.current);
}
return audios;
};
useEffect(() => {
let isActive = true;
@ -190,6 +225,7 @@ function SplitTracksSection({
setPeaks({ user: null, bot: null });
setIsPlaying(false);
setProgress(0);
setPlaybackMode('both');
setIsLoading(true);
async function loadTracks() {
@ -234,12 +270,17 @@ function SplitTracksSection({
let frameId: number;
const updateProgress = () => {
const userAudio = userAudioRef.current;
const botAudio = botAudioRef.current;
const userDuration = Number.isFinite(userAudio?.duration) ? userAudio?.duration ?? 0 : 0;
const botDuration = Number.isFinite(botAudio?.duration) ? botAudio?.duration ?? 0 : 0;
const duration = Math.max(userDuration, botDuration);
const currentTime = Math.max(userAudio?.currentTime ?? 0, botAudio?.currentTime ?? 0);
const activeAudios: HTMLAudioElement[] = [];
if (playbackMode !== 'bot' && userAudioRef.current) {
activeAudios.push(userAudioRef.current);
}
if (playbackMode !== 'user' && botAudioRef.current) {
activeAudios.push(botAudioRef.current);
}
const { duration, currentTime } = getAudioTimelineState(activeAudios);
setProgress(duration > 0 ? Math.min(1, currentTime / duration) : 0);
frameId = window.requestAnimationFrame(updateProgress);
@ -247,7 +288,7 @@ function SplitTracksSection({
frameId = window.requestAnimationFrame(updateProgress);
return () => window.cancelAnimationFrame(frameId);
}, [isPlaying]);
}, [isPlaying, playbackMode]);
const pauseTracks = () => {
userAudioRef.current?.pause();
@ -256,38 +297,68 @@ function SplitTracksSection({
};
const handleTrackEnded = () => {
const userAudio = userAudioRef.current;
const botAudio = botAudioRef.current;
const userDone = !userAudio || userAudio.ended;
const botDone = !botAudio || botAudio.ended;
const activeAudios = getPlaybackAudios(playbackMode);
const activeTracksDone = activeAudios.length > 0 && activeAudios.every((audio) => audio.ended);
if (userDone && botDone) {
if (activeTracksDone) {
setIsPlaying(false);
setProgress(1);
}
};
const handlePlaybackModeChange = async (nextMode: SplitTrackPlaybackMode) => {
if (nextMode === playbackMode) return;
const { currentTime } = getAudioTimelineState(getPlaybackAudios(playbackMode));
const nextAudios = getPlaybackAudios(nextMode);
const { duration } = getAudioTimelineState(nextAudios);
const startTime = duration > 0 && currentTime >= duration - 0.1 ? 0 : currentTime;
userAudioRef.current?.pause();
botAudioRef.current?.pause();
nextAudios.forEach((audio) => syncAudioCurrentTime(audio, startTime));
setPlaybackMode(nextMode);
setProgress(duration > 0 ? Math.min(1, startTime / duration) : 0);
if (!isPlaying) return;
if (nextAudios.length === 0) {
setIsPlaying(false);
return;
}
try {
await Promise.all(nextAudios.map((audio) => audio.play()));
setIsPlaying(true);
} catch (error) {
pauseTracks();
console.error('Error switching split track playback:', error);
}
};
const handleTrackButtonClick = (track: 'user' | 'bot') => {
const nextMode = playbackMode === track ? 'both' : track;
void handlePlaybackModeChange(nextMode);
};
const togglePlayback = async () => {
const userAudio = userAudioRef.current;
const botAudio = botAudioRef.current;
if (!userAudio || !botAudio || !signedUrls.user || !signedUrls.bot) return;
const playbackAudios = getPlaybackAudios(playbackMode);
if (!canPlay || playbackAudios.length === 0) return;
if (isPlaying) {
pauseTracks();
return;
}
const userDuration = Number.isFinite(userAudio.duration) ? userAudio.duration : 0;
const botDuration = Number.isFinite(botAudio.duration) ? botAudio.duration : 0;
const duration = Math.max(userDuration, botDuration);
const currentTime = Math.max(userAudio.currentTime, botAudio.currentTime);
const { duration, currentTime } = getAudioTimelineState(playbackAudios);
const startTime = duration > 0 && currentTime >= duration - 0.1 ? 0 : currentTime;
userAudio.currentTime = Math.min(startTime, userDuration || startTime);
botAudio.currentTime = Math.min(startTime, botDuration || startTime);
userAudioRef.current?.pause();
botAudioRef.current?.pause();
playbackAudios.forEach((audio) => syncAudioCurrentTime(audio, startTime));
try {
await Promise.all([userAudio.play(), botAudio.play()]);
await Promise.all(playbackAudios.map((audio) => audio.play()));
setIsPlaying(true);
} catch (error) {
pauseTracks();
@ -295,8 +366,16 @@ function SplitTracksSection({
}
};
const canPlay = Boolean(signedUrls.user && signedUrls.bot);
const canPlay =
playbackMode === 'both'
? Boolean(signedUrls.user && signedUrls.bot)
: playbackMode === 'user'
? Boolean(signedUrls.user)
: Boolean(signedUrls.bot);
const progressPercent = Math.round(progress * 1000) / 10;
const userTrackActive = playbackMode !== 'bot';
const botTrackActive = playbackMode !== 'user';
const playbackTargetLabel = playbackMode === 'both' ? 'split tracks' : `${playbackMode} track`;
return (
<Card className="border-border">
@ -319,16 +398,42 @@ function SplitTracksSection({
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-sky-600">
<div className="flex items-center gap-2" role="group" aria-label="Playback tracks">
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={userTrackActive}
aria-label={playbackMode === 'user' ? 'Play both tracks' : 'Play user track only'}
onClick={() => handleTrackButtonClick('user')}
className={cn(
'gap-1.5',
userTrackActive
? 'border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300'
: 'text-muted-foreground opacity-60'
)}
>
<UserRound className="h-4 w-4" />
User
</span>
</Button>
<span className="h-4 w-px bg-border" />
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={botTrackActive}
aria-label={playbackMode === 'bot' ? 'Play both tracks' : 'Play bot track only'}
onClick={() => handleTrackButtonClick('bot')}
className={cn(
'gap-1.5',
botTrackActive
? 'border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'text-muted-foreground opacity-60'
)}
>
<Bot className="h-4 w-4" />
Bot
</span>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
@ -360,15 +465,15 @@ function SplitTracksSection({
variant={isPlaying ? 'default' : 'outline'}
onClick={togglePlayback}
disabled={!canPlay}
aria-label={isPlaying ? 'Pause split tracks' : 'Play split tracks'}
aria-label={isPlaying ? `Pause ${playbackTargetLabel}` : `Play ${playbackTargetLabel}`}
className="h-10 w-10 shrink-0"
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="relative h-36 min-w-0 flex-1 overflow-hidden rounded-lg border border-border/70 bg-background">
<div className="absolute left-3 right-3 top-1/2 h-px bg-border/80" />
<WaveformLane peaks={peaks.user} track="user" position="top" />
<WaveformLane peaks={peaks.bot} track="bot" position="bottom" />
<WaveformLane peaks={peaks.user} track="user" position="top" isActive={userTrackActive} />
<WaveformLane peaks={peaks.bot} track="bot" position="bottom" isActive={botTrackActive} />
{canPlay && (
<div className="pointer-events-none absolute inset-x-3 inset-y-3">
<div

View file

@ -45,8 +45,12 @@ import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
type AmbientNoiseConfiguration,
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
DEFAULT_TURN_START_MIN_WORDS,
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
DEFAULT_WORKFLOW_CONFIGURATIONS,
TURN_START_STRATEGY_OPTIONS,
type TurnStartStrategy,
type TurnStopStrategy,
type VoicemailDetectionConfiguration,
type WorkflowConfigurations,
@ -280,6 +284,15 @@ function GeneralSection({
const [maxCallDuration, setMaxCallDuration] = useState(workflowConfigurations.max_call_duration || 600);
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState(workflowConfigurations.max_user_idle_timeout || 10);
const [smartTurnStopSecs, setSmartTurnStopSecs] = useState(workflowConfigurations.smart_turn_stop_secs || 2);
const [turnStartStrategy, setTurnStartStrategy] = useState<TurnStartStrategy>(
workflowConfigurations.turn_start_strategy || "default",
);
const [turnStartMinWords, setTurnStartMinWords] = useState(
workflowConfigurations.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS,
);
const [provisionalVadPauseSecs, setProvisionalVadPauseSecs] = useState(
workflowConfigurations.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
);
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
workflowConfigurations.turn_stop_strategy || "transcription",
);
@ -291,6 +304,9 @@ function GeneralSection({
const [audioUploadError, setAudioUploadError] = useState<string | null>(null);
const ambientFileInputRef = useRef<HTMLInputElement>(null);
const { playingId, toggle: togglePlayback } = useAudioPlayback();
const selectedTurnStartStrategy = TURN_START_STRATEGY_OPTIONS.find(
(option) => option.value === turnStartStrategy,
);
const isDirty = useMemo(() => {
const initAmbient = workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG;
@ -300,10 +316,13 @@ function GeneralSection({
maxCallDuration !== (workflowConfigurations.max_call_duration || 600) ||
maxUserIdleTimeout !== (workflowConfigurations.max_user_idle_timeout || 10) ||
smartTurnStopSecs !== (workflowConfigurations.smart_turn_stop_secs || 2) ||
turnStartStrategy !== (workflowConfigurations.turn_start_strategy || "default") ||
turnStartMinWords !== (workflowConfigurations.turn_start_min_words || DEFAULT_TURN_START_MIN_WORDS) ||
provisionalVadPauseSecs !== (workflowConfigurations.provisional_vad_pause_secs || DEFAULT_PROVISIONAL_VAD_PAUSE_SECS) ||
turnStopStrategy !== (workflowConfigurations.turn_stop_strategy || "transcription") ||
contextCompactionEnabled !== (workflowConfigurations.context_compaction_enabled ?? false)
);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
useUnsavedChanges("general", isDirty);
@ -375,6 +394,9 @@ function GeneralSection({
max_call_duration: maxCallDuration,
max_user_idle_timeout: maxUserIdleTimeout,
smart_turn_stop_secs: smartTurnStopSecs,
turn_start_strategy: turnStartStrategy,
turn_start_min_words: turnStartMinWords,
provisional_vad_pause_secs: provisionalVadPauseSecs,
turn_stop_strategy: turnStopStrategy,
context_compaction_enabled: contextCompactionEnabled,
},
@ -589,6 +611,71 @@ function GeneralSection({
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="turn_start_strategy" className="text-xs">Interruption Strategy</Label>
<Select
value={turnStartStrategy}
onValueChange={(value: TurnStartStrategy) => setTurnStartStrategy(value)}
>
<SelectTrigger id="turn_start_strategy">
<SelectValue placeholder="Select strategy" />
</SelectTrigger>
<SelectContent>
{TURN_START_STRATEGY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{selectedTurnStartStrategy?.description}
</p>
</div>
{turnStartStrategy === "min_words" && (
<div className="space-y-2">
<Label htmlFor="turn_start_min_words" className="text-xs">
Minimum Words Before Interruption
</Label>
<Input
id="turn_start_min_words"
type="number"
step="1"
min="1"
max="10"
value={turnStartMinWords}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= 1) setTurnStartMinWords(value);
}}
/>
<p className="text-xs text-muted-foreground">
Number of transcribed words needed to interrupt while the bot is speaking. Default: {DEFAULT_TURN_START_MIN_WORDS}
</p>
</div>
)}
{turnStartStrategy === "provisional_vad" && (
<div className="space-y-2">
<Label htmlFor="provisional_vad_pause_secs" className="text-xs">
Provisional Pause (seconds)
</Label>
<Input
id="provisional_vad_pause_secs"
type="number"
step="0.1"
min="0.1"
max="5"
value={provisionalVadPauseSecs}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0.1) setProvisionalVadPauseSecs(value);
}}
/>
<p className="text-xs text-muted-foreground">
Seconds to pause bot audio while waiting for transcript confirmation. Default: {DEFAULT_PROVISIONAL_VAD_PAUSE_SECS}
</p>
</div>
)}
</div>
<Separator />

View file

@ -9,6 +9,31 @@ export interface AmbientNoiseConfiguration {
}
export type TurnStopStrategy = 'transcription' | 'turn_analyzer';
export type TurnStartStrategy = 'default' | 'min_words' | 'provisional_vad';
export const DEFAULT_TURN_START_MIN_WORDS = 3;
export const DEFAULT_PROVISIONAL_VAD_PAUSE_SECS = 1.5;
export const TURN_START_STRATEGY_OPTIONS: Array<{
value: TurnStartStrategy;
label: string;
description: string;
}> = [
{
value: 'default',
label: 'Default',
description: 'Use the platform default: external STT turn signals when available, otherwise local VAD.',
},
{
value: 'min_words',
label: 'Minimum words',
description: 'Wait for a minimum number of transcribed words before interrupting bot speech.',
},
{
value: 'provisional_vad',
label: 'Provisional VAD',
description: 'Pause bot audio on voice activity, then confirm the interruption with transcription.',
},
];
export interface VoicemailDetectionConfiguration {
enabled: boolean;
@ -61,6 +86,9 @@ export interface WorkflowConfigurations {
max_call_duration: number; // Maximum call duration in seconds
max_user_idle_timeout: number; // Maximum user idle time in seconds
smart_turn_stop_secs: number; // Timeout in seconds for incomplete turn detection
turn_start_strategy: TurnStartStrategy; // Strategy for detecting start of user turn/interruption
turn_start_min_words: number; // Minimum transcribed words required for minimum-word interruptions
provisional_vad_pause_secs: number; // Seconds to pause bot output while awaiting transcript confirmation
turn_stop_strategy: TurnStopStrategy; // Strategy for detecting end of user turn
dictionary?: string; // Comma-separated words for voice agent to listen for
voicemail_detection?: VoicemailDetectionConfiguration;
@ -78,6 +106,9 @@ export const DEFAULT_WORKFLOW_CONFIGURATIONS: WorkflowConfigurations = {
max_call_duration: 600, // 10 minutes
max_user_idle_timeout: 10, // 10 seconds
smart_turn_stop_secs: 2, // 2 seconds
turn_start_strategy: 'default', // Default to platform-chosen user turn start detection
turn_start_min_words: DEFAULT_TURN_START_MIN_WORDS,
provisional_vad_pause_secs: DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
turn_stop_strategy: 'transcription', // Default to transcription-based detection
dictionary: ''
};