diff --git a/backend/package/yuxi/channels/adapters/wechat/streaming_fallback.py b/backend/package/yuxi/channels/adapters/wechat/streaming_fallback.py new file mode 100644 index 00000000..29a9924b --- /dev/null +++ b/backend/package/yuxi/channels/adapters/wechat/streaming_fallback.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from yuxi.channels.models import DeliveryResult +from yuxi.utils.logging_config import logger + +if TYPE_CHECKING: + from .adapter import WeChatAdapter + +_PARAGRAPH_SPLITTER = re.compile(r"\n{2,}") +_SENTENCE_BOUNDARY = re.compile(r"[。!?.!?\n]") + + +class ParagraphChunker: + def __init__(self, chunk_size: int = 2000, min_chunk_size: int = 100): + self.chunk_size = chunk_size + self.min_chunk_size = min_chunk_size + self._buffer: list[str] = [] + + def feed(self, text: str) -> list[str]: + self._buffer.append(text) + accumulated = "".join(self._buffer) + results: list[str] = [] + + if "\n\n" in accumulated: + paragraphs = _PARAGRAPH_SPLITTER.split(accumulated) + if len(paragraphs) > 1: + for para in paragraphs[:-1]: + stripped = para.strip() + if len(stripped) >= self.min_chunk_size: + results.append(stripped) + self._buffer = [paragraphs[-1]] + + return results + + def flush(self) -> list[str]: + if not self._buffer: + return [] + accumulated = "".join(self._buffer) + self._buffer = [] + if not accumulated.strip(): + return [] + + if len(accumulated) <= self.chunk_size: + return [accumulated.strip()] + + results = [] + remaining = accumulated + while len(remaining) > self.chunk_size: + split_at = self._find_split_point(remaining, self.chunk_size) + results.append(remaining[:split_at].strip()) + remaining = remaining[split_at:].strip() + if remaining.strip(): + results.append(remaining.strip()) + return results + + def _find_split_point(self, text: str, limit: int) -> int: + text_slice = text[:limit] + for sep in ("\n\n", "\n", "。", "!", "?", ". ", "! ", "? "): + idx = text_slice.rfind(sep) + if idx > limit // 2: + return idx + len(sep) + return limit + + +class ProgressPrefixFormatter: + def __init__(self, template: str = "({current}/{total})", enabled: bool = True): + self._template = template + self.enabled = enabled + + def format(self, current: int, total: int) -> str: + if not self.enabled: + return "" + return self._template.format(current=current, total=total) + + +async def send_stream_chunked( + adapter: WeChatAdapter, + chat_id: str, + text: str, + finished: bool = False, +) -> DeliveryResult: + from yuxi.channels.models import ChannelIdentity, ChannelResponse + + identity = ChannelIdentity( + channel_id=adapter.channel_id, + channel_type=adapter.channel_type, + channel_chat_id=chat_id, + channel_user_id="", + ) + + response = ChannelResponse(identity=identity, content=text) + try: + return await adapter.send(response) + except Exception as e: + logger.error(f"[WeChat] Chunked send failed for {chat_id}: {e}") + return DeliveryResult(success=False, error=str(e)) + + +async def send_with_typing( + adapter: WeChatAdapter, + chat_id: str, + text: str, + typing_enabled: bool = True, + is_wecom: bool = False, +) -> DeliveryResult: + if typing_enabled and is_wecom: + try: + await adapter.heartbeat_adapter.send_typing_indicator(adapter.send, chat_id) + except Exception: + pass + + from yuxi.channels.models import ChannelIdentity, ChannelResponse + + identity = ChannelIdentity( + channel_id=adapter.channel_id, + channel_type=adapter.channel_type, + channel_chat_id=chat_id, + channel_user_id="", + ) + + response = ChannelResponse(identity=identity, content=text) + try: + return await adapter.send(response) + except Exception as e: + return DeliveryResult(success=False, error=str(e)) diff --git a/backend/package/yuxi/channels/adapters/wechat/thread_history.py b/backend/package/yuxi/channels/adapters/wechat/thread_history.py new file mode 100644 index 00000000..6e503042 --- /dev/null +++ b/backend/package/yuxi/channels/adapters/wechat/thread_history.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from yuxi.channels.history_injector import HistoryFetcher +from yuxi.channels.models import FetchOptions, HistoricalMessage + +if TYPE_CHECKING: + from typing import Any + + +class WeChatHistoryFetcher(HistoryFetcher): + def __init__(self, mode: str, client: Any = None): + self._mode = mode + self._client = client + + async def fetch_thread_history(self, thread_id: str, options: FetchOptions) -> list[HistoricalMessage]: + if self._mode == "wecom": + return await self._fetch_wecom_history(thread_id, options) + if self._mode == "mp": + return await self._fetch_mp_history(thread_id, options) + if self._mode == "personal": + return await self._fetch_personal_history(thread_id, options) + return [] + + async def fetch_parent_message(self, thread_id: str) -> HistoricalMessage | None: + return None + + async def _fetch_wecom_history(self, thread_id: str, options: FetchOptions) -> list[HistoricalMessage]: + return [] + + async def _fetch_mp_history(self, thread_id: str, options: FetchOptions) -> list[HistoricalMessage]: + return [] + + async def _fetch_personal_history(self, thread_id: str, options: FetchOptions) -> list[HistoricalMessage]: + return [] diff --git a/backend/package/yuxi/channels/adapters/wechat/timeline_adapter.py b/backend/package/yuxi/channels/adapters/wechat/timeline_adapter.py deleted file mode 100644 index 3b79bec7..00000000 --- a/backend/package/yuxi/channels/adapters/wechat/timeline_adapter.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import httpx - -from yuxi.utils.logging_config import logger - -from .mp.client import MPClient -from .wecom.client import WeComClient - - -async def resolve_timeline_wecom( - client: WeComClient, http_client: httpx.AsyncClient, chat_id: str, limit: int = 50 -) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - try: - await client.get_access_token() - return messages - except Exception as e: - logger.warning(f"[WeChat/Timeline] resolve_timeline_wecom failed: {e}") - return messages - - -async def resolve_timeline_mp( - client: MPClient, http_client: httpx.AsyncClient, open_id: str, limit: int = 50 -) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - try: - await client.get_access_token() - return messages - except Exception as e: - logger.warning(f"[WeChat/Timeline] resolve_timeline_mp failed: {e}") - return messages - - -async def resolve_timeline_bridge(bridge_client, chat_id: str, limit: int = 50) -> list[dict[str, Any]]: - try: - return [] - except Exception as e: - logger.warning(f"[WeChat/Timeline] resolve_timeline_bridge failed: {e}") - return []