Files
AstrBot/astrbot/dashboard/services/live_chat_service.py
LIghtJUNction 5535f0c932 Merge branch 'master' into dev
# Conflicts:
#	AGENTS.md
#	astrbot/__init__.py
#	astrbot/cli/utils/__init__.py
#	astrbot/core/astr_agent_tool_exec.py
#	astrbot/core/computer/booters/local.py
#	astrbot/core/cron/manager.py
#	astrbot/core/knowledge_base/kb_mgr.py
#	astrbot/core/knowledge_base/retrieval/manager.py
#	astrbot/core/platform/sources/discord/discord_platform_adapter.py
#	astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
#	astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py
#	astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py
#	astrbot/core/platform/sources/wecom/wecom_adapter.py
#	astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
#	astrbot/core/provider/func_tool_manager.py
#	astrbot/core/provider/sources/mimo_api_common.py
#	astrbot/core/star/star_manager.py
#	astrbot/core/tools/computer_tools/fs.py
#	astrbot/core/tools/computer_tools/python.py
#	astrbot/core/tools/computer_tools/shell.py
#	astrbot/core/tools/message_tools.py
#	astrbot/core/tools/web_search_tools.py
#	astrbot/core/utils/io.py
#	astrbot/core/utils/tencent_record_helper.py
#	dashboard/package.json
#	dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
#	dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff
#	dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2
#	dashboard/src/components/ConfirmDialog.vue
#	dashboard/src/components/chat/Chat.vue
#	dashboard/src/components/chat/ChatInput.vue
#	dashboard/src/components/chat/ChatMessageList.vue
#	dashboard/src/components/chat/ConfigSelector.vue
#	dashboard/src/components/chat/MessageList.vue
#	dashboard/src/components/chat/ProjectDialog.vue
#	dashboard/src/components/chat/ProjectList.vue
#	dashboard/src/components/chat/ProjectView.vue
#	dashboard/src/components/chat/ProviderModelMenu.vue
#	dashboard/src/components/chat/RegenerateMenu.vue
#	dashboard/src/components/chat/StandaloneChat.vue
#	dashboard/src/components/chat/ThreadPanel.vue
#	dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue
#	dashboard/src/components/chat/message_list_comps/MarkdownMessagePart.vue
#	dashboard/src/components/chat/message_list_comps/ReasoningTimeline.vue
#	dashboard/src/components/chat/message_list_comps/RefsSidebar.vue
#	dashboard/src/components/config/UnsavedChangesConfirmDialog.vue
#	dashboard/src/components/extension/MarketPluginCard.vue
#	dashboard/src/components/extension/McpServersSection.vue
#	dashboard/src/components/extension/SkillsSection.vue
#	dashboard/src/components/extension/componentPanel/components/DetailsDialog.vue
#	dashboard/src/components/extension/componentPanel/components/RenameDialog.vue
#	dashboard/src/components/folder/BaseCreateFolderDialog.vue
#	dashboard/src/components/folder/BaseFolderItemSelector.vue
#	dashboard/src/components/folder/BaseMoveToFolderDialog.vue
#	dashboard/src/components/platform/AddNewPlatform.vue
#	dashboard/src/components/provider/AddNewProvider.vue
#	dashboard/src/components/provider/ProviderModelsPanel.vue
#	dashboard/src/components/shared/BackupDialog.vue
#	dashboard/src/components/shared/ChangelogDialog.vue
#	dashboard/src/components/shared/ExtensionCard.vue
#	dashboard/src/components/shared/FileConfigItem.vue
#	dashboard/src/components/shared/KnowledgeBaseSelector.vue
#	dashboard/src/components/shared/ListConfigItem.vue
#	dashboard/src/components/shared/ObjectEditor.vue
#	dashboard/src/components/shared/PersonaForm.vue
#	dashboard/src/components/shared/PluginSetSelector.vue
#	dashboard/src/components/shared/ProviderSelector.vue
#	dashboard/src/components/shared/ReadmeDialog.vue
#	dashboard/src/components/shared/SidebarCustomizer.vue
#	dashboard/src/components/shared/T2ITemplateEditor.vue
#	dashboard/src/components/shared/UninstallConfirmDialog.vue
#	dashboard/src/components/shared/WaitingForRestart.vue
#	dashboard/src/composables/useMessages.ts
#	dashboard/src/composables/useProjects.ts
#	dashboard/src/composables/useProviderModelConfigDialog.ts
#	dashboard/src/composables/useProviderSources.ts
#	dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
#	dashboard/src/i18n/locales/ru-RU/features/extension.json
#	dashboard/src/i18n/locales/ru-RU/features/provider.json
#	dashboard/src/layouts/full/vertical-header/VerticalHeader.vue
#	dashboard/src/scss/_override.scss
#	dashboard/src/stores/common.js
#	dashboard/src/stores/customizer.ts
#	dashboard/src/views/ConfigPage.vue
#	dashboard/src/views/ConversationPage.vue
#	dashboard/src/views/PlatformPage.vue
#	dashboard/src/views/SessionManagementPage.vue
#	dashboard/src/views/WelcomePage.vue
#	dashboard/src/views/alkaid/KnowledgeBase.vue
#	dashboard/src/views/alkaid/LongTermMemory.vue
#	dashboard/src/views/authentication/auth/LoginPage.vue
#	dashboard/src/views/extension/InstalledPluginsTab.vue
#	dashboard/src/views/extension/useExtensionPage.js
#	dashboard/src/views/knowledge-base/DocumentDetail.vue
#	dashboard/src/views/knowledge-base/KBList.vue
#	dashboard/src/views/knowledge-base/components/DocumentsTab.vue
#	dashboard/src/views/knowledge-base/components/SettingsTab.vue
#	dashboard/src/views/knowledge-base/components/TavilyKeyDialog.vue
#	dashboard/src/views/persona/FolderTree.vue
#	dashboard/src/views/persona/MoveToFolderDialog.vue
#	dashboard/src/views/persona/PersonaManager.vue
#	requirements.txt
#	tests/test_local_shell_component.py
#	tests/unit/test_func_tool_manager.py
#	tests/unit/test_web_search_tools.py
2026-07-16 17:39:40 +08:00

1003 lines
37 KiB
Python

from __future__ import annotations
import asyncio
import base64
import json
import os
import re
import time
import uuid
import wave
from collections.abc import Awaitable, Callable
from typing import Any
import jwt
from starlette.websockets import WebSocketDisconnect
from astrbot import logger
from astrbot.core import sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.sources.webchat.message_parts_helper import (
build_webchat_message_parts,
create_attachment_part_from_existing_file,
strip_message_parts_path_fields,
webchat_message_parts_have_content,
)
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from astrbot.dashboard.services.chat_service import (
BotMessageAccumulator,
build_bot_history_content,
collect_plain_text_from_message_parts,
)
SendJson = Callable[[dict], Awaitable[None]]
ReceiveJson = Callable[[], Awaitable[dict]]
CloseWebSocket = Callable[[int, str], Awaitable[None]]
class LiveChatAuthError(Exception):
pass
class LiveChatSession:
"""Live Chat 会话管理器"""
def __init__(self, session_id: str, username: str) -> None:
self.session_id = session_id
self.username = username
self.conversation_id = str(uuid.uuid4())
self.is_speaking = False
self.is_processing = False
self.should_interrupt = False
self.audio_frames: list[bytes] = []
self.current_stamp: str | None = None
self.temp_audio_path: str | None = None
self.chat_subscriptions: dict[str, str] = {}
self.chat_subscription_tasks: dict[str, asyncio.Task] = {}
self.ws_send_lock = asyncio.Lock()
def start_speaking(self, stamp: str) -> None:
self.is_speaking = True
self.current_stamp = stamp
self.audio_frames = []
logger.debug(f"[Live Chat] {self.username} 开始说话 stamp={stamp}")
def add_audio_frame(self, data: bytes) -> None:
if self.is_speaking:
self.audio_frames.append(data)
async def end_speaking(self, stamp: str) -> tuple[str | None, float]:
start_time = time.time()
if not self.is_speaking or stamp != self.current_stamp:
logger.warning(
f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}"
)
return None, 0.0
self.is_speaking = False
if not self.audio_frames:
logger.warning("[Live Chat] 没有音频帧数据")
return None, 0.0
try:
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav")
with wave.open(audio_path, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16000)
for frame in self.audio_frames:
wav_file.writeframes(frame)
self.temp_audio_path = audio_path
logger.info(
f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes"
)
return audio_path, time.time() - start_time
except Exception as exc:
logger.error(f"[Live Chat] 组装 WAV 文件失败: {exc}", exc_info=True)
return None, 0.0
def cleanup(self) -> None:
if self.temp_audio_path and os.path.exists(self.temp_audio_path):
try:
os.remove(self.temp_audio_path)
logger.debug(f"[Live Chat] 已删除临时文件: {self.temp_audio_path}")
except Exception as exc:
logger.warning(f"[Live Chat] 删除临时文件失败: {exc}")
self.temp_audio_path = None
class LiveChatService:
def __init__(
self,
db: Any,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
self.db = db
self.core_lifecycle = core_lifecycle
self.config = core_lifecycle.astrbot_config
self.plugin_manager = core_lifecycle.plugin_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.sessions: dict[str, LiveChatSession] = {}
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
self.webchat_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.attachments_dir, exist_ok=True)
def authenticate_token(
self,
token: str | None,
jwt_secret: str | None = None,
) -> str:
if not token:
raise LiveChatAuthError("Missing authentication token")
jwt_secret = jwt_secret or self.config["dashboard"].get("jwt_secret")
try:
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
return payload["username"]
except jwt.ExpiredSignatureError as exc:
raise LiveChatAuthError("Token expired") from exc
except jwt.InvalidTokenError as exc:
raise LiveChatAuthError("Invalid token") from exc
def create_session(self, username: str) -> LiveChatSession:
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
session = LiveChatSession(session_id, username)
self.sessions[session_id] = session
return session
async def cleanup_session(self, session: LiveChatSession) -> None:
if session.session_id in self.sessions:
await self.cleanup_chat_subscriptions(session)
session.cleanup()
del self.sessions[session.session_id]
async def run_websocket_session(
self,
*,
token: str | None,
force_ct: str | None,
receive_json: ReceiveJson,
send_json: SendJson,
close: CloseWebSocket,
) -> None:
try:
username = self.authenticate_token(token)
except LiveChatAuthError as exc:
await close(1008, str(exc))
return
live_session = self.create_session(username)
logger.info(f"[Live Chat] WebSocket 连接建立: {username}")
try:
while True:
message = await receive_json()
ct = force_ct or message.get("ct", "live")
if ct == "chat":
await self.handle_chat_message(
live_session,
message,
send_json,
)
else:
await self.handle_live_message(
live_session,
message,
send_json,
)
except WebSocketDisconnect as exc:
logger.debug(
f"[Live Chat] WebSocket disconnected: {username}, code={exc.code}"
)
except Exception as exc:
logger.error(f"[Live Chat] WebSocket 错误: {exc}", exc_info=True)
finally:
await self.cleanup_session(live_session)
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
async def create_attachment_from_file(
self, filename: str, attach_type: str, display_name: str | None = None
) -> dict | None:
return await create_attachment_part_from_existing_file(
filename,
attach_type=attach_type,
insert_attachment=self.db.insert_attachment,
attachments_dir=self.attachments_dir,
fallback_dirs=[self.webchat_img_dir],
display_name=display_name,
)
@staticmethod
def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> dict:
supported = [
"web_search_baidu",
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
]
web_search_results = {}
tool_call_parts = [
p
for p in accumulated_parts
if p.get("type") == "tool_call" and p.get("tool_calls")
]
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") not in supported or not tool_call.get(
"result"
):
continue
try:
result_data = json.loads(tool_call["result"])
for item in result_data.get("results", []):
if idx := item.get("index"):
web_search_results[idx] = {
"url": item.get("url"),
"title": item.get("title"),
"snippet": item.get("snippet"),
}
except (json.JSONDecodeError, KeyError):
pass
if not web_search_results:
return {}
ref_indices = {
match.strip() for match in re.findall(r"<ref>(.*?)</ref>", accumulated_text)
}
used_refs = []
for ref_index in ref_indices:
if ref_index not in web_search_results:
continue
payload = {"index": ref_index, **web_search_results[ref_index]}
if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
payload["favicon"] = favicon
used_refs.append(payload)
return {"used": used_refs} if used_refs else {}
async def save_bot_message(
self,
webchat_conv_id: str,
message_parts: list[dict],
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
):
new_his = build_bot_history_content(
message_parts,
agent_stats=agent_stats,
refs=refs,
)
return await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
llm_checkpoint_id=llm_checkpoint_id,
)
async def send_chat_payload(
self,
session: LiveChatSession,
payload: dict,
send_json: SendJson,
) -> None:
async with session.ws_send_lock:
await send_json(payload)
async def forward_chat_subscription(
self,
session: LiveChatSession,
chat_session_id: str,
request_id: str,
send_json: SendJson,
) -> None:
back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id, chat_session_id
)
try:
while True:
result = await back_queue.get()
if not result:
continue
await self.send_chat_payload(
session, {"ct": "chat", **result}, send_json
)
except asyncio.CancelledError:
pass
except Exception as exc:
logger.error(
f"[Live Chat] chat subscription forward failed ({chat_session_id}): {exc}",
exc_info=True,
)
finally:
webchat_queue_mgr.remove_back_queue(request_id)
if session.chat_subscriptions.get(chat_session_id) == request_id:
session.chat_subscriptions.pop(chat_session_id, None)
session.chat_subscription_tasks.pop(chat_session_id, None)
async def ensure_chat_subscription(
self,
session: LiveChatSession,
chat_session_id: str,
send_json: SendJson,
) -> str:
existing_request_id = session.chat_subscriptions.get(chat_session_id)
existing_task = session.chat_subscription_tasks.get(chat_session_id)
if existing_request_id and existing_task and not existing_task.done():
return existing_request_id
request_id = f"ws_sub_{uuid.uuid4().hex}"
session.chat_subscriptions[chat_session_id] = request_id
task = asyncio.create_task(
self.forward_chat_subscription(
session,
chat_session_id,
request_id,
send_json,
),
name=f"chat_ws_sub_{chat_session_id}",
)
session.chat_subscription_tasks[chat_session_id] = task
return request_id
async def cleanup_chat_subscriptions(self, session: LiveChatSession) -> None:
tasks = list(session.chat_subscription_tasks.values())
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
for request_id in list(session.chat_subscriptions.values()):
webchat_queue_mgr.remove_back_queue(request_id)
session.chat_subscriptions.clear()
session.chat_subscription_tasks.clear()
async def handle_chat_message(
self,
session: LiveChatSession,
message: dict,
send_json: SendJson,
) -> None:
msg_type = message.get("t")
if msg_type == "bind":
chat_session_id = message.get("session_id")
if not isinstance(chat_session_id, str) or not chat_session_id:
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": "session_id is required",
"code": "INVALID_MESSAGE_FORMAT",
},
send_json,
)
return
request_id = await self.ensure_chat_subscription(
session, chat_session_id, send_json
)
await self.send_chat_payload(
session,
{
"ct": "chat",
"type": "session_bound",
"session_id": chat_session_id,
"message_id": request_id,
},
send_json,
)
return
if msg_type == "interrupt":
session.should_interrupt = True
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": "INTERRUPTED",
"code": "INTERRUPTED",
},
send_json,
)
return
if msg_type != "send":
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": f"Unsupported message type: {msg_type}",
"code": "INVALID_MESSAGE_FORMAT",
},
send_json,
)
return
if session.is_processing:
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": "Session is busy",
"code": "PROCESSING_ERROR",
},
send_json,
)
return
payload = message.get("message")
session_id = message.get("session_id") or session.session_id
message_id = message.get("message_id") or str(uuid.uuid4())
selected_provider = message.get("selected_provider")
selected_model = message.get("selected_model")
selected_stt_provider = message.get("selected_stt_provider")
selected_tts_provider = message.get("selected_tts_provider")
persona_prompt = message.get("persona_prompt")
show_reasoning = message.get("show_reasoning")
enable_streaming = message.get("enable_streaming", True)
if not isinstance(payload, list):
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": "message must be list",
"code": "INVALID_MESSAGE_FORMAT",
},
send_json,
)
return
message_parts = await self.build_chat_message_parts(payload)
has_content = webchat_message_parts_have_content(message_parts)
if not has_content:
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": "Message content is empty",
"code": "INVALID_MESSAGE_FORMAT",
},
send_json,
)
return
await self.ensure_chat_subscription(session, session_id, send_json)
session.is_processing = True
session.should_interrupt = False
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
llm_checkpoint_id = str(uuid.uuid4())
pending_bot_message_flusher = None
try:
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
await chat_queue.put(
(
session.username,
session_id,
{
"message": message_parts,
"selected_provider": selected_provider,
"selected_model": selected_model,
"selected_stt_provider": selected_stt_provider,
"selected_tts_provider": selected_tts_provider,
"persona_prompt": persona_prompt,
"show_reasoning": show_reasoning,
"enable_streaming": enable_streaming,
"message_id": message_id,
"llm_checkpoint_id": llm_checkpoint_id,
},
),
)
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
saved_user_record = await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=session_id,
content={"type": "user", "message": message_parts_for_storage},
sender_id=session.username,
sender_name=session.username,
llm_checkpoint_id=llm_checkpoint_id,
)
await self.send_chat_payload(
session,
{
"ct": "chat",
"type": "user_message_saved",
"data": {
"id": saved_user_record.id,
"created_at": to_utc_isoformat(saved_user_record.created_at),
"llm_checkpoint_id": llm_checkpoint_id,
},
},
send_json,
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
async def flush_pending_bot_message():
nonlocal message_accumulator, agent_stats, refs
if not (message_accumulator.has_content() or refs or agent_stats):
return None
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
extracted_refs = self.extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as exc:
logger.exception(
f"[Live Chat] Failed to extract web search refs: {exc}",
exc_info=True,
)
extracted_refs = refs
saved_record = await self.save_bot_message(
session_id,
message_parts_to_save,
agent_stats,
extracted_refs,
llm_checkpoint_id,
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
return saved_record
pending_bot_message_flusher = flush_pending_bot_message
async def send_attachment_saved_event(part: dict | None) -> None:
if not part or not part.get("attachment_id") or not part.get("type"):
return
await self.send_chat_payload(
session,
{
"ct": "chat",
"type": "attachment_saved",
"data": {
"id": part["attachment_id"],
"type": part["type"],
},
},
send_json,
)
while True:
if session.should_interrupt:
session.should_interrupt = False
await flush_pending_bot_message()
break
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except TimeoutError:
continue
if not result:
continue
if result.get("message_id") and result.get("message_id") != message_id:
continue
result_text = result.get("data", "")
result_type = result.get("type")
streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
if chain_type == "agent_stats":
try:
parsed_agent_stats = json.loads(result_text)
agent_stats = parsed_agent_stats
await self.send_chat_payload(
session,
{
"ct": "chat",
"type": "agent_stats",
"data": parsed_agent_stats,
},
send_json,
)
except Exception:
pass
continue
outgoing = {"ct": "chat", **result}
await self.send_chat_payload(session, outgoing, send_json)
if result_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
elif result_type == "image":
filename = str(result_text).replace("[IMAGE]", "")
part = await self.create_attachment_from_file(filename, "image")
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "record":
filename = str(result_text).replace("[RECORD]", "")
part = await self.create_attachment_from_file(filename, "record")
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "file":
filename = str(result_text).replace("[FILE]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename,
"file",
display_name=display_name,
)
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
elif result_type == "video":
filename = str(result_text).replace("[VIDEO]", "", 1)
display_name = None
if "|" in filename:
filename, display_name = filename.split("|", 1)
part = await self.create_attachment_from_file(
filename,
"video",
display_name=display_name,
)
message_accumulator.add_attachment(part)
await send_attachment_saved_event(part)
should_save = False
if result_type == "end":
should_save = bool(
message_accumulator.has_content() or refs or agent_stats
)
elif (streaming and result_type == "complete") or not streaming:
if chain_type not in (
"tool_call",
"tool_call_result",
"agent_stats",
):
should_save = True
if should_save:
saved_record = await flush_pending_bot_message()
if saved_record:
await self.send_chat_payload(
session,
{
"ct": "chat",
"type": "message_saved",
"data": {
"id": saved_record.id,
"created_at": to_utc_isoformat(
saved_record.created_at
),
"llm_checkpoint_id": llm_checkpoint_id,
},
},
send_json,
)
if result_type == "end":
break
except Exception as exc:
logger.error(f"[Live Chat] 处理 chat 消息失败: {exc}", exc_info=True)
await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
"data": f"处理失败: {str(exc)}",
"code": "PROCESSING_ERROR",
},
send_json,
)
finally:
try:
if pending_bot_message_flusher is not None:
await pending_bot_message_flusher()
except Exception as exc:
logger.exception(
f"[Live Chat] Failed to persist pending chat message: {exc}",
exc_info=True,
)
session.is_processing = False
webchat_queue_mgr.remove_back_queue(message_id)
async def build_chat_message_parts(self, message: list[dict]) -> list[dict]:
return await build_webchat_message_parts(
message,
get_attachment_by_id=self.db.get_attachment_by_id,
strict=False,
)
async def handle_live_message(
self,
session: LiveChatSession,
message: dict,
send_json: SendJson,
) -> None:
msg_type = message.get("t")
if msg_type == "start_speaking":
stamp = message.get("stamp")
if not stamp:
logger.warning("[Live Chat] start_speaking 缺少 stamp")
return
session.start_speaking(stamp)
return
if msg_type == "speaking_part":
audio_data_b64 = message.get("data")
if not audio_data_b64:
return
try:
audio_data = base64.b64decode(audio_data_b64)
session.add_audio_frame(audio_data)
except Exception as exc:
logger.error(f"[Live Chat] 解码音频数据失败: {exc}")
return
if msg_type == "end_speaking":
stamp = message.get("stamp")
if not stamp:
logger.warning("[Live Chat] end_speaking 缺少 stamp")
return
audio_path, assemble_duration = await session.end_speaking(stamp)
if not audio_path:
await send_json({"t": "error", "data": "音频组装失败"})
return
await self.process_audio(session, audio_path, assemble_duration, send_json)
return
if msg_type == "interrupt":
session.should_interrupt = True
logger.info(f"[Live Chat] 用户打断: {session.username}")
async def process_audio(
self,
session: LiveChatSession,
audio_path: str,
assemble_duration: float,
send_json: SendJson,
) -> None:
try:
await send_json(
{"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
)
wav_assembly_finish_time = time.time()
session.is_processing = True
session.should_interrupt = False
ctx = self.plugin_manager.context
stt_provider = ctx.provider_manager.stt_provider_insts[0]
if not stt_provider:
logger.error("[Live Chat] STT Provider 未配置")
await send_json({"t": "error", "data": "语音识别服务未配置"})
return
await send_json({"t": "metrics", "data": {"stt": stt_provider.meta().type}})
user_text = await stt_provider.get_text(audio_path)
if not user_text:
logger.warning("[Live Chat] STT 识别结果为空")
return
logger.info(f"[Live Chat] STT 结果: {user_text}")
await send_json(
{
"t": "user_msg",
"data": {"text": user_text, "ts": int(time.time() * 1000)},
}
)
conversation_id = session.conversation_id
queue = webchat_queue_mgr.get_or_create_queue(conversation_id)
message_id = str(uuid.uuid4())
payload = {
"message_id": message_id,
"message": [{"type": "plain", "text": user_text}],
"action_type": "live",
}
await queue.put((session.username, conversation_id, payload))
back_queue = webchat_queue_mgr.get_or_create_back_queue(
message_id, conversation_id
)
bot_text = ""
audio_playing = False
try:
while True:
if session.should_interrupt:
logger.info("[Live Chat] 检测到用户打断")
await send_json({"t": "stop_play"})
await self.save_interrupted_message(
session, user_text, bot_text
)
while not back_queue.empty():
try:
back_queue.get_nowait()
except asyncio.QueueEmpty:
break
break
try:
result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
except TimeoutError:
continue
if not result:
continue
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
)
continue
result_type = result.get("type")
result_chain_type = result.get("chain_type")
data = result.get("data", "")
if result_chain_type == "agent_stats":
try:
stats = json.loads(data)
await send_json(
{
"t": "metrics",
"data": {
"llm_ttft": stats.get("time_to_first_token", 0),
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
}
)
except Exception as exc:
logger.error(f"[Live Chat] 解析 AgentStats 失败: {exc}")
continue
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
await send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as exc:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {exc}")
continue
if result_type == "plain":
bot_text += data
elif result_type == "audio_chunk":
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
speak_to_first_frame_latency = (
time.time() - wav_assembly_finish_time
)
await send_json(
{
"t": "metrics",
"data": {
"speak_to_first_frame": speak_to_first_frame_latency
},
}
)
text = result.get("text")
if text:
await send_json(
{
"t": "bot_text_chunk",
"data": {"text": text},
}
)
await send_json(
{
"t": "response",
"data": data,
}
)
elif result_type in ["complete", "end"]:
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
if not audio_playing:
await send_json(
{
"t": "bot_msg",
"data": {
"text": bot_text,
"ts": int(time.time() * 1000),
},
}
)
await send_json({"t": "end"})
wav_to_tts_duration = time.time() - wav_assembly_finish_time
await send_json(
{
"t": "metrics",
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
}
)
break
finally:
webchat_queue_mgr.remove_back_queue(message_id)
except Exception as exc:
logger.error(f"[Live Chat] 处理音频失败: {exc}", exc_info=True)
await send_json({"t": "error", "data": f"处理失败: {str(exc)}"})
finally:
session.is_processing = False
session.should_interrupt = False
@staticmethod
async def save_interrupted_message(
session: LiveChatSession, user_text: str, bot_text: str
) -> None:
interrupted_text = bot_text + " [用户打断]"
logger.info(f"[Live Chat] 保存打断消息: {interrupted_text}")
try:
timestamp = int(time.time() * 1000)
logger.info(
f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})"
)
if bot_text:
logger.info(
f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})"
)
except Exception as exc:
logger.error(f"[Live Chat] 记录消息失败: {exc}", exc_info=True)
__all__ = ["LiveChatAuthError", "LiveChatService", "LiveChatSession"]