Add chatId in ws connection
This commit is contained in:
@@ -66,10 +66,14 @@ Optional input audio filtering can be enabled through `audio_filter`. See
|
|||||||
Product endpoint:
|
Product endpoint:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ws://localhost:8000/ws-product
|
ws://localhost:8000/ws-product?chatId=customer-chat-001
|
||||||
```
|
```
|
||||||
|
|
||||||
This endpoint uses a stable JSON/base64 protocol named `va.ws.v1`. It is meant for browser, mobile, or other product applications that should not depend on Pipecat's internal protobuf frame schema.
|
This endpoint uses a stable JSON/base64 protocol named `va.ws.v1`. It is meant for browser, mobile, or other product applications that should not depend on Pipecat's internal protobuf frame schema.
|
||||||
|
For FastGPT sessions, pass `chatId` on the websocket URL. The engine uses
|
||||||
|
that id for FastGPT server-side memory; if the id has existing FastGPT
|
||||||
|
records, the assistant greets with `欢迎回来继续对话`, otherwise it uses the
|
||||||
|
FastGPT app opener.
|
||||||
|
|
||||||
Start a session:
|
Start a session:
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ Start a session:
|
|||||||
{
|
{
|
||||||
"type": "session.start",
|
"type": "session.start",
|
||||||
"protocol": "va.ws.v1",
|
"protocol": "va.ws.v1",
|
||||||
|
"chatId": "customer-chat-001",
|
||||||
"audio": {
|
"audio": {
|
||||||
"encoding": "pcm_s16le",
|
"encoding": "pcm_s16le",
|
||||||
"sample_rate": 16000,
|
"sample_rate": 16000,
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"user_speech_timeout_sec": 0.2
|
"user_speech_timeout_sec": 0.2
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"greeting_mode": "fastgpt_opener"
|
"greeting_mode": "fastgpt_opener",
|
||||||
|
"fastgpt_reconnect_greeting": "欢迎回来继续对话"
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"stt": {
|
"stt": {
|
||||||
@@ -67,7 +68,6 @@
|
|||||||
"base_url": "http://localhost:3030",
|
"base_url": "http://localhost:3030",
|
||||||
"model": "my-voice-app",
|
"model": "my-voice-app",
|
||||||
"app_id": "6a153aed53e3f8d9f2744905",
|
"app_id": "6a153aed53e3f8d9f2744905",
|
||||||
"chat_id": null,
|
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"detail": false,
|
"detail": false,
|
||||||
"timeout_sec": 60.0
|
"timeout_sec": 60.0
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"greeting_mode": "fastgpt_opener",
|
"greeting_mode": "fastgpt_opener",
|
||||||
|
"fastgpt_reconnect_greeting": "欢迎回来继续对话,请告诉我准备好了之后继续办理",
|
||||||
"response_state": {
|
"response_state": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"tag": "state",
|
"tag": "state",
|
||||||
@@ -76,7 +77,6 @@
|
|||||||
"base_url": "http://localhost:3030",
|
"base_url": "http://localhost:3030",
|
||||||
"model": "my-voice-app",
|
"model": "my-voice-app",
|
||||||
"app_id": "691eddaa53e3f8d9f25f1370",
|
"app_id": "691eddaa53e3f8d9f25f1370",
|
||||||
"chat_id": null,
|
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"detail": false,
|
"detail": false,
|
||||||
"timeout_sec": 60.0
|
"timeout_sec": 60.0
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"greeting_mode": "fastgpt_opener",
|
"greeting_mode": "fastgpt_opener",
|
||||||
|
"fastgpt_reconnect_greeting": "欢迎回来继续对话",
|
||||||
"response_state": {
|
"response_state": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"tag": "state",
|
"tag": "state",
|
||||||
@@ -83,7 +84,6 @@
|
|||||||
"base_url": "http://localhost:3030",
|
"base_url": "http://localhost:3030",
|
||||||
"model": "my-voice-app",
|
"model": "my-voice-app",
|
||||||
"app_id": "6a153aed53e3f8d9f2744905",
|
"app_id": "6a153aed53e3f8d9f2744905",
|
||||||
"chat_id": null,
|
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"detail": false,
|
"detail": false,
|
||||||
"timeout_sec": 60.0
|
"timeout_sec": 60.0
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"greeting_mode": "fastgpt_opener",
|
"greeting_mode": "fastgpt_opener",
|
||||||
|
"fastgpt_reconnect_greeting": "欢迎回来继续对话",
|
||||||
"response_state": {
|
"response_state": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"tag": "state",
|
"tag": "state",
|
||||||
@@ -73,7 +74,6 @@
|
|||||||
"base_url": "http://localhost:3030",
|
"base_url": "http://localhost:3030",
|
||||||
"model": "my-voice-app",
|
"model": "my-voice-app",
|
||||||
"app_id": "6a153aed53e3f8d9f2744905",
|
"app_id": "6a153aed53e3f8d9f2744905",
|
||||||
"chat_id": null,
|
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"detail": false,
|
"detail": false,
|
||||||
"timeout_sec": 60.0
|
"timeout_sec": 60.0
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"greeting_mode": "fastgpt_opener",
|
"greeting_mode": "fastgpt_opener",
|
||||||
|
"fastgpt_reconnect_greeting": "欢迎回来继续对话",
|
||||||
"response_state": {
|
"response_state": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"tag": "state",
|
"tag": "state",
|
||||||
@@ -76,7 +77,6 @@
|
|||||||
"base_url": "http://localhost:3030",
|
"base_url": "http://localhost:3030",
|
||||||
"model": "my-voice-app",
|
"model": "my-voice-app",
|
||||||
"app_id": "6a153aed53e3f8d9f2744905",
|
"app_id": "6a153aed53e3f8d9f2744905",
|
||||||
"chat_id": null,
|
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"detail": false,
|
"detail": false,
|
||||||
"timeout_sec": 60.0
|
"timeout_sec": 60.0
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class AgentConfig:
|
|||||||
system_prompt: str = "You are a helpful, friendly voice assistant."
|
system_prompt: str = "You are a helpful, friendly voice assistant."
|
||||||
greeting: str | None = None
|
greeting: str | None = None
|
||||||
greeting_mode: str = "generated"
|
greeting_mode: str = "generated"
|
||||||
|
fastgpt_reconnect_greeting: str = "欢迎回来继续对话"
|
||||||
response_state: ResponseStateConfig = field(default_factory=ResponseStateConfig)
|
response_state: ResponseStateConfig = field(default_factory=ResponseStateConfig)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ class LLMConfig:
|
|||||||
"""LLM backend selection via ``provider``.
|
"""LLM backend selection via ``provider``.
|
||||||
|
|
||||||
Set ``provider`` to ``"openai"`` (alias ``"llm"``) for OpenAI-compatible chat
|
Set ``provider`` to ``"openai"`` (alias ``"llm"``) for OpenAI-compatible chat
|
||||||
completions, or ``"fastgpt"`` for FastGPT server-side memory via ``chat_id``.
|
completions, or ``"fastgpt"`` for FastGPT server-side memory via runtime ``chatId``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider: str = "openai"
|
provider: str = "openai"
|
||||||
|
|||||||
@@ -271,6 +271,39 @@ class FastGPTLLMService(LLMService):
|
|||||||
logger.warning(f"FastGPT chat init error: {exc}")
|
logger.warning(f"FastGPT chat init error: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def has_chat_history(self) -> bool:
|
||||||
|
"""Return whether FastGPT has persisted records for this chatId."""
|
||||||
|
if not self._app_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.get_chat_records(
|
||||||
|
appId=self._app_id,
|
||||||
|
chatId=self._chat_id,
|
||||||
|
offset=0,
|
||||||
|
pageSize=1,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
records = data.get("data", {}).get("list", [])
|
||||||
|
return isinstance(records, list) and bool(records)
|
||||||
|
except FastGPTError as exc:
|
||||||
|
logger.warning(f"FastGPT chat records failed: {exc}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning(f"FastGPT chat records HTTP error: {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"FastGPT chat records error: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def fetch_session_greeting_text(self, reconnect_greeting: str) -> str | None:
|
||||||
|
"""Use opener for a new chatId and a fixed greeting for reconnects."""
|
||||||
|
if await self.has_chat_history():
|
||||||
|
logger.info(f"FastGPT chatId={self._chat_id} has history; using reconnect greeting")
|
||||||
|
return reconnect_greeting.strip() or None
|
||||||
|
|
||||||
|
logger.info(f"FastGPT chatId={self._chat_id} has no history; using app opener")
|
||||||
|
return await self.fetch_welcome_text()
|
||||||
|
|
||||||
async def _close_active_response(self) -> None:
|
async def _close_active_response(self) -> None:
|
||||||
response = self._active_response
|
response = self._active_response
|
||||||
self._active_response = None
|
self._active_response = None
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ from .transcript_stream import ProductTranscriptStreamProcessor
|
|||||||
from .turn_start import InterruptionGateUserTurnStartStrategy
|
from .turn_start import InterruptionGateUserTurnStartStrategy
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_id_from_websocket(websocket) -> str | None:
|
||||||
|
query_params = getattr(websocket, "query_params", None)
|
||||||
|
if not query_params:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for name in ("chatId", "chat_id"):
|
||||||
|
value = query_params.get(name)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def run_voice_pipeline(websocket, config: EngineConfig) -> None:
|
async def run_voice_pipeline(websocket, config: EngineConfig) -> None:
|
||||||
await run_pipeline_with_serializer(
|
await run_pipeline_with_serializer(
|
||||||
websocket,
|
websocket,
|
||||||
@@ -93,7 +105,7 @@ async def run_pipeline_with_serializer(
|
|||||||
stt = create_stt_service(config.services.stt, config.audio)
|
stt = create_stt_service(config.services.stt, config.audio)
|
||||||
|
|
||||||
llm_config = config.services.llm
|
llm_config = config.services.llm
|
||||||
chat_id = llm_config.chat_id or f"voice_{uuid.uuid4().hex[:16]}"
|
chat_id = _chat_id_from_websocket(websocket) or f"voice_{uuid.uuid4().hex[:16]}"
|
||||||
llm = create_llm_service(
|
llm = create_llm_service(
|
||||||
llm_config,
|
llm_config,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
@@ -200,7 +212,9 @@ async def run_pipeline_with_serializer(
|
|||||||
await task.queue_frames([TTSSpeakFrame(config.agent.greeting)])
|
await task.queue_frames([TTSSpeakFrame(config.agent.greeting)])
|
||||||
elif config.agent.greeting_mode == "fastgpt_opener":
|
elif config.agent.greeting_mode == "fastgpt_opener":
|
||||||
if isinstance(llm, FastGPTLLMService):
|
if isinstance(llm, FastGPTLLMService):
|
||||||
welcome = await llm.fetch_welcome_text()
|
welcome = await llm.fetch_session_greeting_text(
|
||||||
|
config.agent.fastgpt_reconnect_greeting
|
||||||
|
)
|
||||||
if welcome:
|
if welcome:
|
||||||
await task.queue_frames([TTSSpeakFrame(welcome)])
|
await task.queue_frames([TTSSpeakFrame(welcome)])
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -103,10 +103,12 @@ class ProductWebsocketSerializer(FrameSerializer):
|
|||||||
|
|
||||||
message_type = message.get("type")
|
message_type = message.get("type")
|
||||||
if message_type == "session.start":
|
if message_type == "session.start":
|
||||||
|
chat_id = message.get("chatId") or message.get("chat_id")
|
||||||
return InputTransportMessageFrame(
|
return InputTransportMessageFrame(
|
||||||
message={
|
message={
|
||||||
"type": "session.started",
|
"type": "session.started",
|
||||||
"protocol": self.protocol,
|
"protocol": self.protocol,
|
||||||
|
"chatId": chat_id if isinstance(chat_id, str) else None,
|
||||||
"audio": {
|
"audio": {
|
||||||
"encoding": "pcm_s16le",
|
"encoding": "pcm_s16le",
|
||||||
"sample_rate": self._sample_rate,
|
"sample_rate": self._sample_rate,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def create_llm_service(
|
|||||||
return FastGPTLLMService(
|
return FastGPTLLMService(
|
||||||
api_key=config.api_key,
|
api_key=config.api_key,
|
||||||
base_url=config.base_url or "http://localhost:3000",
|
base_url=config.base_url or "http://localhost:3000",
|
||||||
chat_id=chat_id or config.chat_id,
|
chat_id=chat_id,
|
||||||
app_id=config.app_id,
|
app_id=config.app_id,
|
||||||
greeting_prompt=greeting_prompt,
|
greeting_prompt=greeting_prompt,
|
||||||
timeout=config.timeout_sec,
|
timeout=config.timeout_sec,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function defaultWsUrl() {
|
|||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
url: document.getElementById("ws-url"),
|
url: document.getElementById("ws-url"),
|
||||||
|
chatId: document.getElementById("chat-id"),
|
||||||
connectBtn: document.getElementById("connect-btn"),
|
connectBtn: document.getElementById("connect-btn"),
|
||||||
statusDot: document.getElementById("status-dot"),
|
statusDot: document.getElementById("status-dot"),
|
||||||
statusText: document.getElementById("status-text"),
|
statusText: document.getElementById("status-text"),
|
||||||
@@ -69,6 +70,21 @@ const els = {
|
|||||||
sendBtn: document.getElementById("send-btn"),
|
sendBtn: document.getElementById("send-btn"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function wsUrlWithChatId() {
|
||||||
|
const rawUrl = (els.url.value || "").trim();
|
||||||
|
const chatId = (els.chatId.value || "").trim();
|
||||||
|
if (!rawUrl || !chatId) return rawUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl, location.href);
|
||||||
|
url.searchParams.set("chatId", chatId);
|
||||||
|
return url.href;
|
||||||
|
} catch (_) {
|
||||||
|
const separator = rawUrl.includes("?") ? "&" : "?";
|
||||||
|
return `${rawUrl}${separator}chatId=${encodeURIComponent(chatId)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
ws: null,
|
ws: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -110,6 +126,7 @@ function setStatus(kind, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setConnectButton() {
|
function setConnectButton() {
|
||||||
|
els.chatId.disabled = state.connected || state.connecting;
|
||||||
if (state.connecting) {
|
if (state.connecting) {
|
||||||
els.connectBtn.textContent = "Connecting…";
|
els.connectBtn.textContent = "Connecting…";
|
||||||
els.connectBtn.disabled = true;
|
els.connectBtn.disabled = true;
|
||||||
@@ -874,7 +891,7 @@ function handleEvent(event) {
|
|||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (state.connected || state.connecting) return;
|
if (state.connected || state.connecting) return;
|
||||||
const url = (els.url.value || "").trim();
|
const url = wsUrlWithChatId();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setStatus("error", "Missing URL");
|
setStatus("error", "Missing URL");
|
||||||
return;
|
return;
|
||||||
@@ -912,6 +929,7 @@ async function connect() {
|
|||||||
state.ws = ws;
|
state.ws = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
|
const chatId = (els.chatId.value || "").trim();
|
||||||
const startMessage = {
|
const startMessage = {
|
||||||
type: "session.start",
|
type: "session.start",
|
||||||
protocol: PROTOCOL,
|
protocol: PROTOCOL,
|
||||||
@@ -921,6 +939,9 @@ async function connect() {
|
|||||||
channels: CHANNELS,
|
channels: CHANNELS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
if (chatId) {
|
||||||
|
startMessage.chatId = chatId;
|
||||||
|
}
|
||||||
|
|
||||||
state.connecting = false;
|
state.connecting = false;
|
||||||
state.connected = true;
|
state.connected = true;
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="connection__field connection__field--chat">
|
||||||
|
<span>Chat ID</span>
|
||||||
|
<input
|
||||||
|
id="chat-id"
|
||||||
|
type="text"
|
||||||
|
placeholder="optional chatId"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button id="connect-btn" class="btn btn--primary" type="button">
|
<button id="connect-btn" class="btn btn--primary" type="button">
|
||||||
Connect
|
Connect
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -304,6 +304,10 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection__field--chat {
|
||||||
|
flex: 0 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.connection__field span {
|
.connection__field span {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -1013,6 +1017,10 @@ body {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection__field--chat {
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ws-log__entry,
|
.ws-log__entry,
|
||||||
.ws-log__group-header {
|
.ws-log__group-header {
|
||||||
grid-template-columns: 54px 38px minmax(0, 1fr);
|
grid-template-columns: 54px 38px minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user