diff --git a/README.md b/README.md index e51943e..ee52c48 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,14 @@ Optional input audio filtering can be enabled through `audio_filter`. See Product endpoint: ```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. +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: @@ -77,6 +81,7 @@ Start a session: { "type": "session.start", "protocol": "va.ws.v1", + "chatId": "customer-chat-001", "audio": { "encoding": "pcm_s16le", "sample_rate": 16000, diff --git a/config/fastgpt.example.json b/config/fastgpt.example.json index ca88edb..8ba2153 100644 --- a/config/fastgpt.example.json +++ b/config/fastgpt.example.json @@ -45,7 +45,8 @@ "user_speech_timeout_sec": 0.2 }, "agent": { - "greeting_mode": "fastgpt_opener" + "greeting_mode": "fastgpt_opener", + "fastgpt_reconnect_greeting": "欢迎回来继续对话" }, "services": { "stt": { @@ -67,7 +68,6 @@ "base_url": "http://localhost:3030", "model": "my-voice-app", "app_id": "6a153aed53e3f8d9f2744905", - "chat_id": null, "variables": {}, "detail": false, "timeout_sec": 60.0 diff --git a/config/fastgpt.state.xfyun.json b/config/fastgpt.state.xfyun.json index c992636..f9da9a0 100644 --- a/config/fastgpt.state.xfyun.json +++ b/config/fastgpt.state.xfyun.json @@ -49,6 +49,7 @@ }, "agent": { "greeting_mode": "fastgpt_opener", + "fastgpt_reconnect_greeting": "欢迎回来继续对话,请告诉我准备好了之后继续办理", "response_state": { "enabled": true, "tag": "state", @@ -76,7 +77,6 @@ "base_url": "http://localhost:3030", "model": "my-voice-app", "app_id": "691eddaa53e3f8d9f25f1370", - "chat_id": null, "variables": {}, "detail": false, "timeout_sec": 60.0 diff --git a/config/fastgpt.xfyun.dfn.json b/config/fastgpt.xfyun.dfn.json index a40a3b4..cce3d90 100644 --- a/config/fastgpt.xfyun.dfn.json +++ b/config/fastgpt.xfyun.dfn.json @@ -56,6 +56,7 @@ }, "agent": { "greeting_mode": "fastgpt_opener", + "fastgpt_reconnect_greeting": "欢迎回来继续对话", "response_state": { "enabled": false, "tag": "state", @@ -83,7 +84,6 @@ "base_url": "http://localhost:3030", "model": "my-voice-app", "app_id": "6a153aed53e3f8d9f2744905", - "chat_id": null, "variables": {}, "detail": false, "timeout_sec": 60.0 diff --git a/config/fastgpt.xfyun.json b/config/fastgpt.xfyun.json index b91ee6d..4af5d15 100644 --- a/config/fastgpt.xfyun.json +++ b/config/fastgpt.xfyun.json @@ -46,6 +46,7 @@ }, "agent": { "greeting_mode": "fastgpt_opener", + "fastgpt_reconnect_greeting": "欢迎回来继续对话", "response_state": { "enabled": false, "tag": "state", @@ -73,7 +74,6 @@ "base_url": "http://localhost:3030", "model": "my-voice-app", "app_id": "6a153aed53e3f8d9f2744905", - "chat_id": null, "variables": {}, "detail": false, "timeout_sec": 60.0 diff --git a/config/fastgpt.xfyun.supertts.json b/config/fastgpt.xfyun.supertts.json index b454295..f643f84 100644 --- a/config/fastgpt.xfyun.supertts.json +++ b/config/fastgpt.xfyun.supertts.json @@ -49,6 +49,7 @@ }, "agent": { "greeting_mode": "fastgpt_opener", + "fastgpt_reconnect_greeting": "欢迎回来继续对话", "response_state": { "enabled": false, "tag": "state", @@ -76,7 +77,6 @@ "base_url": "http://localhost:3030", "model": "my-voice-app", "app_id": "6a153aed53e3f8d9f2744905", - "chat_id": null, "variables": {}, "detail": false, "timeout_sec": 60.0 diff --git a/engine/config.py b/engine/config.py index 60a31a7..bb060fe 100644 --- a/engine/config.py +++ b/engine/config.py @@ -126,6 +126,7 @@ class AgentConfig: system_prompt: str = "You are a helpful, friendly voice assistant." greeting: str | None = None greeting_mode: str = "generated" + fastgpt_reconnect_greeting: str = "欢迎回来继续对话" response_state: ResponseStateConfig = field(default_factory=ResponseStateConfig) @@ -134,7 +135,7 @@ class LLMConfig: """LLM backend selection via ``provider``. 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" diff --git a/engine/fastgpt_llm.py b/engine/fastgpt_llm.py index d3fb01b..935d0a7 100644 --- a/engine/fastgpt_llm.py +++ b/engine/fastgpt_llm.py @@ -271,6 +271,39 @@ class FastGPTLLMService(LLMService): logger.warning(f"FastGPT chat init error: {exc}") 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: response = self._active_response self._active_response = None diff --git a/engine/pipeline.py b/engine/pipeline.py index f0af44c..252ee3e 100644 --- a/engine/pipeline.py +++ b/engine/pipeline.py @@ -47,6 +47,18 @@ from .transcript_stream import ProductTranscriptStreamProcessor 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: await run_pipeline_with_serializer( websocket, @@ -93,7 +105,7 @@ async def run_pipeline_with_serializer( stt = create_stt_service(config.services.stt, config.audio) 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_config, chat_id=chat_id, @@ -200,7 +212,9 @@ async def run_pipeline_with_serializer( await task.queue_frames([TTSSpeakFrame(config.agent.greeting)]) elif config.agent.greeting_mode == "fastgpt_opener": if isinstance(llm, FastGPTLLMService): - welcome = await llm.fetch_welcome_text() + welcome = await llm.fetch_session_greeting_text( + config.agent.fastgpt_reconnect_greeting + ) if welcome: await task.queue_frames([TTSSpeakFrame(welcome)]) else: diff --git a/engine/product_protocol.py b/engine/product_protocol.py index 9de8af0..bce39dc 100644 --- a/engine/product_protocol.py +++ b/engine/product_protocol.py @@ -103,10 +103,12 @@ class ProductWebsocketSerializer(FrameSerializer): message_type = message.get("type") if message_type == "session.start": + chat_id = message.get("chatId") or message.get("chat_id") return InputTransportMessageFrame( message={ "type": "session.started", "protocol": self.protocol, + "chatId": chat_id if isinstance(chat_id, str) else None, "audio": { "encoding": "pcm_s16le", "sample_rate": self._sample_rate, diff --git a/engine/services.py b/engine/services.py index 20778bc..8fbd916 100644 --- a/engine/services.py +++ b/engine/services.py @@ -61,7 +61,7 @@ def create_llm_service( return FastGPTLLMService( api_key=config.api_key, 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, greeting_prompt=greeting_prompt, timeout=config.timeout_sec, diff --git a/examples/webpage/app.js b/examples/webpage/app.js index ea6010a..2648e69 100644 --- a/examples/webpage/app.js +++ b/examples/webpage/app.js @@ -44,6 +44,7 @@ function defaultWsUrl() { const els = { url: document.getElementById("ws-url"), + chatId: document.getElementById("chat-id"), connectBtn: document.getElementById("connect-btn"), statusDot: document.getElementById("status-dot"), statusText: document.getElementById("status-text"), @@ -69,6 +70,21 @@ const els = { 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 = { ws: null, connected: false, @@ -110,6 +126,7 @@ function setStatus(kind, text) { } function setConnectButton() { + els.chatId.disabled = state.connected || state.connecting; if (state.connecting) { els.connectBtn.textContent = "Connecting…"; els.connectBtn.disabled = true; @@ -874,7 +891,7 @@ function handleEvent(event) { async function connect() { if (state.connected || state.connecting) return; - const url = (els.url.value || "").trim(); + const url = wsUrlWithChatId(); if (!url) { setStatus("error", "Missing URL"); return; @@ -912,6 +929,7 @@ async function connect() { state.ws = ws; ws.addEventListener("open", () => { + const chatId = (els.chatId.value || "").trim(); const startMessage = { type: "session.start", protocol: PROTOCOL, @@ -921,6 +939,9 @@ async function connect() { channels: CHANNELS, }, }; + if (chatId) { + startMessage.chatId = chatId; + } state.connecting = false; state.connected = true; diff --git a/examples/webpage/index.html b/examples/webpage/index.html index 0d0f76a..deb87f9 100644 --- a/examples/webpage/index.html +++ b/examples/webpage/index.html @@ -25,6 +25,16 @@ autocomplete="off" /> + diff --git a/examples/webpage/styles.css b/examples/webpage/styles.css index 0854611..b50e872 100644 --- a/examples/webpage/styles.css +++ b/examples/webpage/styles.css @@ -304,6 +304,10 @@ body { flex: 1; } +.connection__field--chat { + flex: 0 1 220px; +} + .connection__field span { font-size: 11px; color: var(--text-dim); @@ -1013,6 +1017,10 @@ body { align-items: stretch; } + .connection__field--chat { + flex-basis: auto; + } + .ws-log__entry, .ws-log__group-header { grid-template-columns: 54px 38px minmax(0, 1fr);