diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 66c6ba5419..8a447557f8 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -95,11 +95,52 @@ async def _word_cnt(self, text: str) -> int: word_count = len([c for c in text if c.isalnum()]) return word_count - async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float: - """分段回复 计算间隔时间""" + @staticmethod + def _group_segment_chain( + chain: list[BaseMessageComponent], + ) -> list[list[BaseMessageComponent]]: + """Build the bubbles sent by segmented reply. + + Plain and non-inline components start a new bubble; a Face attaches + to the preceding text bubble and glues the Plain following it back + into the same bubble, so an inline emoji never becomes a bubble of + its own and never leaves the next clause as a bubble of its own + (#10047). Adjacent Plain components without an emoji between them — + the output of segmentation-words (#3959) — keep their deliberate + split. + """ + segments: list[list[BaseMessageComponent]] = [] + for comp in chain: + prev_type = segments[-1][-1].type if segments else None + if prev_type == ComponentType.Face and comp.type == ComponentType.Plain: + segments[-1].append(comp) + elif comp.type == ComponentType.Face and prev_type in ( + ComponentType.Plain, + ComponentType.Face, + ): + segments[-1].append(comp) + else: + segments.append([comp]) + return segments + + async def _calc_comp_interval( + self, + comps: BaseMessageComponent | list[BaseMessageComponent], + ) -> float: + """分段回复 计算间隔时间 + + ``comps`` may also be a sequence of components sharing one bubble; + the log-method interval is then computed from the Plain word counts + of that bubble, counted per component and summed. + """ + if not isinstance(comps, list): + comps = [comps] if self.interval_method == "log": - if isinstance(comp, Comp.Plain): - wc = await self._word_cnt(comp.text) + wc = 0 + for comp in comps: + if isinstance(comp, Comp.Plain): + wc += await self._word_cnt(comp.text) + if wc: i = math.log(wc + 1, self.log_base) return random.uniform(i, i + 0.5) return random.uniform(1, 1.75) @@ -273,19 +314,22 @@ async def process( f"actual_chain: {result.chain}", ) return - for comp in result.chain: - i = await self._calc_comp_interval(comp) + segments = self._group_segment_chain(result.chain) + for segment in segments: + i = await self._calc_comp_interval(segment) await asyncio.sleep(i) try: - if comp.type in need_separately: - await event.send(result.derive([comp])) + if segment[0].type in need_separately: + await event.send(result.derive(segment)) else: - await event.send(result.derive([*header_comps, comp])) + await event.send( + result.derive([*header_comps, *segment]), + ) header_comps.clear() except Exception as e: logger.error( "Failed to send the message chain: " - f"chain = {MessageChain([comp])}, error = {e}", + f"chain = {MessageChain(segment)}, error = {e}", exc_info=True, ) else: diff --git a/tests/unit/test_respond_stage_segmented_reply.py b/tests/unit/test_respond_stage_segmented_reply.py new file mode 100644 index 0000000000..b860fc737d --- /dev/null +++ b/tests/unit/test_respond_stage_segmented_reply.py @@ -0,0 +1,98 @@ +"""Regression tests for segmented reply bubble grouping (#10047). + +With segmented reply enabled, every component used to be sent as its own +message, so an inline Face in the middle of a sentence split the text into +several bubbles. The stage now treats an inline Face as glue: it attaches to +the preceding text bubble and keeps the following Plain in the same bubble, +while adjacent Plain components from the segmentation-words feature (#3959) +keep their deliberate split. +""" + +import math + +import pytest + +import astrbot.core.message.components as Comp +from astrbot.core.pipeline.respond.stage import RespondStage + + +def test_inline_face_glues_the_whole_sentence_into_one_bubble(): + stage = RespondStage() + chain = [ + Comp.Plain(text="好的"), + Comp.Face(id=277), + Comp.Plain(text=",我知道了!"), + ] + + segments = stage._group_segment_chain(chain) + + assert segments == [chain] + + +def test_leading_face_joins_the_following_text_bubble(): + stage = RespondStage() + face = Comp.Face(id=277) + chain = [face, Comp.Plain(text="你好呀")] + + segments = stage._group_segment_chain(chain) + + assert segments == [[face, chain[1]]] + + +def test_adjacent_plain_components_keep_separate_bubbles(): + stage = RespondStage() + first = Comp.Plain(text="第一段。") + second = Comp.Plain(text="第二段。") + + segments = stage._group_segment_chain([first, second]) + + assert segments == [[first], [second]] + + +def test_components_that_need_separate_sending_stay_alone(): + stage = RespondStage() + record = Comp.Record(file="file:///tmp/a.wav") + chain = [Comp.Plain(text="看这个"), record, Comp.Plain(text="好听吗")] + + segments = stage._group_segment_chain(chain) + + assert segments == [[chain[0]], [record], [chain[2]]] + + +def test_face_after_media_stays_its_own_bubble(): + stage = RespondStage() + record = Comp.Record(file="file:///tmp/a.wav") + face = Comp.Face(id=277) + chain = [Comp.Plain(text="听"), record, face] + + segments = stage._group_segment_chain(chain) + + assert segments == [[chain[0]], [record], [face]] + + +def test_face_glue_does_not_absorb_media(): + stage = RespondStage() + record = Comp.Record(file="file:///tmp/a.wav") + chain = [Comp.Plain(text="好的"), Comp.Face(id=277), record] + + segments = stage._group_segment_chain(chain) + + assert segments == [[chain[0], chain[1]], [record]] + + +@pytest.mark.asyncio +async def test_log_interval_for_a_bubble_uses_total_plain_word_count(): + stage = RespondStage() + stage.interval_method = "log" + stage.log_base = 10.0 + + # "hello" + "world" -> 2 words -> log10(3) ~= 0.477 + bubble = await stage._calc_comp_interval( + [Comp.Plain(text="hello"), Comp.Face(id=277), Comp.Plain(text="world")], + ) + lower, upper = math.log(3, 10), math.log(3, 10) + 0.5 + assert lower <= bubble <= upper + + # A bubble without text keeps the non-Plain interval. + face_only = await stage._calc_comp_interval(Comp.Face(id=277)) + assert 1 <= face_only <= 1.75