mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: add message before tool calls (#185)
This commit is contained in:
parent
8b5a36e55c
commit
ec58356276
10 changed files with 126 additions and 19 deletions
|
|
@ -96,6 +96,7 @@ class RFNodeDTO(BaseModel):
|
|||
class EdgeDataDTO(BaseModel):
|
||||
label: str = Field(..., min_length=1)
|
||||
condition: str = Field(..., min_length=1)
|
||||
transition_speech: Optional[str] = None
|
||||
|
||||
|
||||
class RFEdgeDTO(BaseModel):
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pipecat.frames.frames import (
|
|||
CancelFrame,
|
||||
EndFrame,
|
||||
FunctionCallResultProperties,
|
||||
TTSSpeakFrame,
|
||||
)
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
|
|
@ -94,6 +95,10 @@ class PipecatEngine:
|
|||
# Controls whether user input should be muted
|
||||
self._mute_pipeline: bool = False
|
||||
|
||||
# Mute state for queued TTSSpeakFrames (transition speech, custom tool messages)
|
||||
# "idle" = not muting, "waiting" = speech queued, "playing" = bot speaking it
|
||||
self._queued_speech_mute_state: str = "idle"
|
||||
|
||||
# Tracks whether the bot is currently speaking (for allow_interrupt logic)
|
||||
self._bot_is_speaking: bool = False
|
||||
|
||||
|
|
@ -204,7 +209,12 @@ class PipecatEngine:
|
|||
|
||||
return render_template(prompt, self._call_context_vars)
|
||||
|
||||
async def _create_transition_func(self, name: str, transition_to_node: str):
|
||||
async def _create_transition_func(
|
||||
self,
|
||||
name: str,
|
||||
transition_to_node: str,
|
||||
transition_speech: Optional[str] = None,
|
||||
):
|
||||
async def transition_func(function_call_params: FunctionCallParams) -> None:
|
||||
"""Inner function that handles the node change tool calls"""
|
||||
logger.info(f"LLM Function Call EXECUTED: {name}")
|
||||
|
|
@ -217,6 +227,14 @@ class PipecatEngine:
|
|||
# Perform variable extraction before transitioning to new node
|
||||
await self._perform_variable_extraction_if_needed(self._current_node)
|
||||
|
||||
# Queue transition speech before switching nodes
|
||||
if transition_speech:
|
||||
logger.info(f"Playing transition speech: {transition_speech}")
|
||||
self._queued_speech_mute_state = "waiting"
|
||||
await self.task.queue_frame(
|
||||
TTSSpeakFrame(transition_speech, append_to_context=False)
|
||||
)
|
||||
|
||||
# Set context for the new node, so that when the function call result
|
||||
# frame is received by LLMContextAggregator and an LLM generation
|
||||
# is done, we have updated context and functions
|
||||
|
|
@ -260,14 +278,19 @@ class PipecatEngine:
|
|||
return transition_func
|
||||
|
||||
async def _register_transition_function_with_llm(
|
||||
self, name: str, transition_to_node: str
|
||||
self,
|
||||
name: str,
|
||||
transition_to_node: str,
|
||||
transition_speech: Optional[str] = None,
|
||||
):
|
||||
logger.debug(
|
||||
f"Registering function {name} to transition to node {transition_to_node} with LLM"
|
||||
)
|
||||
|
||||
# Create transition function
|
||||
transition_func = await self._create_transition_func(name, transition_to_node)
|
||||
transition_func = await self._create_transition_func(
|
||||
name, transition_to_node, transition_speech
|
||||
)
|
||||
|
||||
# Register function with LLM
|
||||
self.llm.register_function(
|
||||
|
|
@ -437,7 +460,9 @@ class PipecatEngine:
|
|||
if not node.is_end:
|
||||
for outgoing_edge in node.out_edges:
|
||||
await self._register_transition_function_with_llm(
|
||||
outgoing_edge.get_function_name(), outgoing_edge.target
|
||||
outgoing_edge.get_function_name(),
|
||||
outgoing_edge.target,
|
||||
outgoing_edge.transition_speech,
|
||||
)
|
||||
|
||||
# Register custom tool handlers for this node
|
||||
|
|
@ -655,13 +680,20 @@ class PipecatEngine:
|
|||
# Track bot speaking state from frames
|
||||
if isinstance(frame, BotStartedSpeakingFrame):
|
||||
self._bot_is_speaking = True
|
||||
if self._queued_speech_mute_state == "waiting":
|
||||
self._queued_speech_mute_state = "playing"
|
||||
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||
self._bot_is_speaking = False
|
||||
self._queued_speech_mute_state = "idle"
|
||||
|
||||
# Always mute if pipeline is shutting down
|
||||
if self._mute_pipeline:
|
||||
return True
|
||||
|
||||
# Mute while queued speech (transition/tool message) is pending or playing
|
||||
if self._queued_speech_mute_state != "idle":
|
||||
return True
|
||||
|
||||
# Mute if bot is speaking and current node doesn't allow interruption
|
||||
if self._bot_is_speaking and self._current_node:
|
||||
# If we should not allow interruption, mute the pipeline
|
||||
|
|
|
|||
|
|
@ -189,6 +189,18 @@ class CustomToolManager:
|
|||
logger.info(f"Arguments: {function_call_params.arguments}")
|
||||
|
||||
try:
|
||||
# Queue custom message before executing the API call
|
||||
config = tool.definition.get("config", {}) if tool.definition else {}
|
||||
custom_message = config.get("customMessage", "")
|
||||
if custom_message:
|
||||
logger.info(
|
||||
f"Playing custom message before HTTP tool: {custom_message}"
|
||||
)
|
||||
self._engine._queued_speech_mute_state = "waiting"
|
||||
await self._engine.task.queue_frame(
|
||||
TTSSpeakFrame(custom_message, append_to_context=False)
|
||||
)
|
||||
|
||||
result = await execute_http_tool(
|
||||
tool=tool,
|
||||
arguments=function_call_params.arguments,
|
||||
|
|
@ -373,6 +385,7 @@ class CustomToolManager:
|
|||
|
||||
if message_type == "custom" and custom_message:
|
||||
logger.info(f"Playing pre-transfer message: {custom_message}")
|
||||
self._engine._queued_speech_mute_state = "waiting"
|
||||
await self._engine.task.queue_frame(TTSSpeakFrame(custom_message))
|
||||
|
||||
# Get organization ID for provider configuration
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class Edge:
|
|||
|
||||
self.label = data.label
|
||||
self.condition = data.condition
|
||||
self.transition_speech = data.transition_speech
|
||||
|
||||
self.data = data
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -133,7 +135,11 @@ export function EndCallToolConfig({
|
|||
</label>
|
||||
</div>
|
||||
{messageType === "custom" && (
|
||||
<div className="pl-8">
|
||||
<div className="pl-8 space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
CredentialSelector,
|
||||
type HttpMethod,
|
||||
|
|
@ -33,6 +35,8 @@ export interface HttpApiToolConfigProps {
|
|||
onParametersChange: (parameters: ToolParameter[]) => void;
|
||||
timeoutMs: number;
|
||||
onTimeoutMsChange: (timeout: number) => void;
|
||||
customMessage: string;
|
||||
onCustomMessageChange: (message: string) => void;
|
||||
}
|
||||
|
||||
export function HttpApiToolConfig({
|
||||
|
|
@ -52,6 +56,8 @@ export function HttpApiToolConfig({
|
|||
onParametersChange,
|
||||
timeoutMs,
|
||||
onTimeoutMsChange,
|
||||
customMessage,
|
||||
onCustomMessageChange,
|
||||
}: HttpApiToolConfigProps) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -126,6 +132,23 @@ export function HttpApiToolConfig({
|
|||
showValidation
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label>Custom Message</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Optional message the AI will speak before executing this tool (e.g., "Let me look that up for you")
|
||||
</Label>
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
placeholder="e.g., Let me check that for you, one moment please."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4 mt-4">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import {useState } from "react";
|
||||
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -167,7 +169,11 @@ export function TransferCallToolConfig({
|
|||
</label>
|
||||
</div>
|
||||
{messageType === "custom" && (
|
||||
<div className="pl-8">
|
||||
<div className="pl-8 space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => onCustomMessageChange(e.target.value)}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ interface HttpApiConfigWithParams {
|
|||
credential_uuid?: string;
|
||||
parameters?: ToolParameter[];
|
||||
timeout_ms?: number;
|
||||
customMessage?: string;
|
||||
}
|
||||
|
||||
export default function ToolDetailPage() {
|
||||
|
|
@ -59,6 +60,9 @@ export default function ToolDetailPage() {
|
|||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Shared form state
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
|
||||
// HTTP API form state
|
||||
const [httpMethod, setHttpMethod] = useState<HttpMethod>("POST");
|
||||
const [url, setUrl] = useState("");
|
||||
|
|
@ -69,7 +73,6 @@ export default function ToolDetailPage() {
|
|||
|
||||
// End Call form state
|
||||
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
|
||||
const [endCallCustomMessage, setEndCallCustomMessage] = useState("");
|
||||
const [endCallReason, setEndCallReason] = useState(false);
|
||||
const [endCallReasonDescription, setEndCallReasonDescription] = useState("");
|
||||
|
||||
|
|
@ -83,7 +86,6 @@ export default function ToolDetailPage() {
|
|||
// Transfer Call form state
|
||||
const [transferDestination, setTransferDestination] = useState("");
|
||||
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
|
||||
const [transferCustomMessage, setTransferCustomMessage] = useState("");
|
||||
const [transferTimeout, setTransferTimeout] = useState(30);
|
||||
|
||||
// Redirect if not authenticated
|
||||
|
|
@ -129,12 +131,12 @@ export default function ToolDetailPage() {
|
|||
const config = tool.definition?.config as EndCallConfig | undefined;
|
||||
if (config) {
|
||||
setEndCallMessageType(config.messageType || "none");
|
||||
setEndCallCustomMessage(config.customMessage || "");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
setEndCallReason(config.endCallReason ?? false);
|
||||
setEndCallReasonDescription(config.endCallReasonDescription || "");
|
||||
} else {
|
||||
setEndCallMessageType("none");
|
||||
setEndCallCustomMessage("");
|
||||
setCustomMessage("");
|
||||
setEndCallReason(false);
|
||||
setEndCallReasonDescription("");
|
||||
}
|
||||
|
|
@ -144,12 +146,12 @@ export default function ToolDetailPage() {
|
|||
if (config) {
|
||||
setTransferDestination(config.destination || "");
|
||||
setTransferMessageType(config.messageType || "none");
|
||||
setTransferCustomMessage(config.customMessage || "");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
setTransferTimeout(config.timeout ?? 30);
|
||||
} else {
|
||||
setTransferDestination("");
|
||||
setTransferMessageType("none");
|
||||
setTransferCustomMessage("");
|
||||
setCustomMessage("");
|
||||
setTransferTimeout(30);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -160,6 +162,7 @@ export default function ToolDetailPage() {
|
|||
setUrl(config.url || "");
|
||||
setCredentialUuid(config.credential_uuid || "");
|
||||
setTimeoutMs(config.timeout_ms || 5000);
|
||||
setCustomMessage(config.customMessage || "");
|
||||
|
||||
// Convert headers object to array
|
||||
if (config.headers) {
|
||||
|
|
@ -243,7 +246,7 @@ export default function ToolDetailPage() {
|
|||
type: "end_call",
|
||||
config: {
|
||||
messageType: endCallMessageType,
|
||||
customMessage: endCallMessageType === "custom" ? endCallCustomMessage : undefined,
|
||||
customMessage: endCallMessageType === "custom" ? customMessage : undefined,
|
||||
endCallReason,
|
||||
endCallReasonDescription: endCallReason ? endCallReasonDescription || undefined : undefined,
|
||||
},
|
||||
|
|
@ -260,7 +263,7 @@ export default function ToolDetailPage() {
|
|||
config: {
|
||||
destination: transferDestination,
|
||||
messageType: transferMessageType,
|
||||
customMessage: transferMessageType === "custom" ? transferCustomMessage : undefined,
|
||||
customMessage: transferMessageType === "custom" ? customMessage : undefined,
|
||||
timeout: transferTimeout,
|
||||
},
|
||||
},
|
||||
|
|
@ -291,6 +294,7 @@ export default function ToolDetailPage() {
|
|||
parameters:
|
||||
validParameters.length > 0 ? validParameters : undefined,
|
||||
timeout_ms: timeoutMs,
|
||||
customMessage: customMessage || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -462,8 +466,8 @@ const data = await response.json();`;
|
|||
onDescriptionChange={setDescription}
|
||||
messageType={endCallMessageType}
|
||||
onMessageTypeChange={setEndCallMessageType}
|
||||
customMessage={endCallCustomMessage}
|
||||
onCustomMessageChange={setEndCallCustomMessage}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
endCallReason={endCallReason}
|
||||
onEndCallReasonChange={handleEndCallReasonChange}
|
||||
endCallReasonDescription={endCallReasonDescription}
|
||||
|
|
@ -479,8 +483,8 @@ const data = await response.json();`;
|
|||
onDestinationChange={setTransferDestination}
|
||||
messageType={transferMessageType}
|
||||
onMessageTypeChange={setTransferMessageType}
|
||||
customMessage={transferCustomMessage}
|
||||
onCustomMessageChange={setTransferCustomMessage}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
timeout={transferTimeout}
|
||||
onTimeoutChange={setTransferTimeout}
|
||||
/>
|
||||
|
|
@ -502,6 +506,8 @@ const data = await response.json();`;
|
|||
onParametersChange={setParameters}
|
||||
timeoutMs={timeoutMs}
|
||||
onTimeoutMsChange={setTimeoutMs}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,17 +25,19 @@ interface EdgeDetailsDialogProps {
|
|||
const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDialogProps) => {
|
||||
const [condition, setCondition] = useState(data?.condition ?? '');
|
||||
const [label, setLabel] = useState(data?.label ?? '');
|
||||
const [transitionSpeech, setTransitionSpeech] = useState(data?.transition_speech ?? '');
|
||||
|
||||
// Update form state when data changes (e.g., from undo/redo)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCondition(data?.condition ?? '');
|
||||
setLabel(data?.label ?? '');
|
||||
setTransitionSpeech(data?.transition_speech ?? '');
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ condition: condition, label: label });
|
||||
onSave({ condition: condition, label: label, transition_speech: transitionSpeech || undefined });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
|
@ -77,6 +79,22 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
onChange={(e) => setCondition(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Transition Speech</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Optional text the assistant will speak right before transitioning to the node.
|
||||
This text will not be attached in Conversation Context. Use this as simple filler to reduce latency.
|
||||
</Label>
|
||||
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={transitionSpeech}
|
||||
placeholder="e.g. Let me transfer you to our billing department..."
|
||||
onChange={(e) => setTransitionSpeech(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export type FlowNode = {
|
|||
export type FlowEdgeData = {
|
||||
condition: string;
|
||||
label: string;
|
||||
transition_speech?: string;
|
||||
invalid?: boolean;
|
||||
validationMessage?: string | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue