From e606a3037eca8a77a4b48442548e4a7303bc0e6e Mon Sep 17 00:00:00 2001 From: Shxiao101 Date: Sat, 12 Sep 2026 08:16:03 +0900 Subject: [PATCH 1/5] fix: keep inline Face components in the same bubble during segmented reply --- astrbot/core/pipeline/respond/stage.py | 69 +++++++++++++++---- .../test_respond_stage_segmented_reply.py | 56 +++++++++++++++ 2 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_respond_stage_segmented_reply.py diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 66c6ba5419..cb0b45ff25 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -49,6 +49,10 @@ class RespondStage(Stage): Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), } + # Inline components that belong to the sentence itself; segmented reply + # keeps a run of them in the same bubble instead of splitting the text. + INLINE_SEGMENT_TYPES = {ComponentType.Plain, ComponentType.Face} + async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.config = ctx.astrbot_config @@ -95,14 +99,50 @@ 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]]: + """Group consecutive inline components into one bubble per group. + + Each returned segment is either a run of inline components (a whole + sentence with inline faces) or a single component that is sent on + its own. + """ + segments: list[list[BaseMessageComponent]] = [] + inline_group: list[BaseMessageComponent] = [] + for comp in chain: + if comp.type in RespondStage.INLINE_SEGMENT_TYPES: + inline_group.append(comp) + continue + if inline_group: + segments.append(inline_group) + inline_group = [] + segments.append([comp]) + if inline_group: + segments.append(inline_group) + 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 total Plain word + count of that bubble. + """ + if isinstance(comps, list): + text = "".join(comp.text for comp in comps if isinstance(comp, Comp.Plain)) + else: + text = comps.text if isinstance(comps, Comp.Plain) else "" if self.interval_method == "log": - if isinstance(comp, Comp.Plain): - wc = await self._word_cnt(comp.text) - i = math.log(wc + 1, self.log_base) - return random.uniform(i, i + 0.5) - return random.uniform(1, 1.75) + if not text: + return random.uniform(1, 1.75) + wc = await self._word_cnt(text) + i = math.log(wc + 1, self.log_base) + return random.uniform(i, i + 0.5) # random return random.uniform(self.interval[0], self.interval[1]) @@ -273,19 +313,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..196198fed4 --- /dev/null +++ b/tests/unit/test_respond_stage_segmented_reply.py @@ -0,0 +1,56 @@ +"""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 groups consecutive inline components +(Plain / Face) so a sentence stays in one bubble. +""" + +import math + +import pytest + +import astrbot.core.message.components as Comp +from astrbot.core.pipeline.respond.stage import RespondStage + + +def test_inline_face_stays_in_the_same_bubble_as_text(): + stage = RespondStage() + chain = [ + Comp.Plain(text="好的"), + Comp.Face(id=277), + Comp.Plain(text=",我知道了!"), + ] + + segments = stage._group_segment_chain(chain) + + assert len(segments) == 1 + assert segments[0] == chain + + +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]]] + + +@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 From afa42248a5a38dc0d15d14e461a7c1137bb92388 Mon Sep 17 00:00:00 2001 From: Shxiao101 Date: Sat, 12 Sep 2026 08:39:52 +0900 Subject: [PATCH 2/5] test: count bubble word count per Plain component to fix flaky interval bounds --- astrbot/core/pipeline/respond/stage.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index cb0b45ff25..9672030945 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -130,19 +130,20 @@ async def _calc_comp_interval( """分段回复 计算间隔时间 ``comps`` may also be a sequence of components sharing one bubble; - the log-method interval is then computed from the total Plain word - count of that bubble. + the log-method interval is then computed from the Plain word counts + of that bubble, counted per component and summed. """ - if isinstance(comps, list): - text = "".join(comp.text for comp in comps if isinstance(comp, Comp.Plain)) - else: - text = comps.text if isinstance(comps, Comp.Plain) else "" + if not isinstance(comps, list): + comps = [comps] if self.interval_method == "log": - if not text: - return random.uniform(1, 1.75) - wc = await self._word_cnt(text) - i = math.log(wc + 1, self.log_base) - return random.uniform(i, i + 0.5) + 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) # random return random.uniform(self.interval[0], self.interval[1]) From 9623682dda4cf38c0b0bbb8a9f51e6486eabaab7 Mon Sep 17 00:00:00 2001 From: Shxiao101 Date: Sat, 12 Sep 2026 16:54:01 +0900 Subject: [PATCH 3/5] refactor: attach Face to the adjacent text bubble instead of merging Plain runs --- astrbot/core/pipeline/respond/stage.py | 35 +++++++++------- .../test_respond_stage_segmented_reply.py | 42 ++++++++++++++++--- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 9672030945..436e5de4bc 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -49,10 +49,6 @@ class RespondStage(Stage): Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), } - # Inline components that belong to the sentence itself; segmented reply - # keeps a run of them in the same bubble instead of splitting the text. - INLINE_SEGMENT_TYPES = {ComponentType.Plain, ComponentType.Face} - async def initialize(self, ctx: PipelineContext) -> None: self.ctx = ctx self.config = ctx.astrbot_config @@ -103,24 +99,31 @@ async def _word_cnt(self, text: str) -> int: def _group_segment_chain( chain: list[BaseMessageComponent], ) -> list[list[BaseMessageComponent]]: - """Group consecutive inline components into one bubble per group. + """Build the bubbles sent by segmented reply. - Each returned segment is either a run of inline components (a whole - sentence with inline faces) or a single component that is sent on - its own. + Every Plain and every non-inline component starts a new bubble; a + Face attaches to the preceding text bubble, or to the following + Plain when the chain starts with a Face, so an inline emoji never + becomes a bubble of its own while adjacent Plain components keep + their deliberate split (#10047, #3959). """ segments: list[list[BaseMessageComponent]] = [] - inline_group: list[BaseMessageComponent] = [] for comp in chain: - if comp.type in RespondStage.INLINE_SEGMENT_TYPES: - inline_group.append(comp) + if ( + comp.type == ComponentType.Face + and segments + and segments[-1][-1].type in (ComponentType.Plain, ComponentType.Face) + ): + segments[-1].append(comp) continue - if inline_group: - segments.append(inline_group) - inline_group = [] segments.append([comp]) - if inline_group: - segments.append(inline_group) + if ( + len(segments) >= 2 + and segments[0][0].type == ComponentType.Face + and segments[1][0].type == ComponentType.Plain + ): + segments[1][:0] = segments[0] + segments.pop(0) return segments async def _calc_comp_interval( diff --git a/tests/unit/test_respond_stage_segmented_reply.py b/tests/unit/test_respond_stage_segmented_reply.py index 196198fed4..7eb580498d 100644 --- a/tests/unit/test_respond_stage_segmented_reply.py +++ b/tests/unit/test_respond_stage_segmented_reply.py @@ -2,8 +2,10 @@ 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 groups consecutive inline components -(Plain / Face) so a sentence stays in one bubble. +several bubbles. The stage now attaches a Face to the preceding text bubble +(or to the following Plain when the chain starts with a Face) while adjacent +Plain components from the segmentation-words feature (#3959) keep their +deliberate split. """ import math @@ -14,7 +16,7 @@ from astrbot.core.pipeline.respond.stage import RespondStage -def test_inline_face_stays_in_the_same_bubble_as_text(): +def test_inline_face_rides_with_the_preceding_text_bubble(): stage = RespondStage() chain = [ Comp.Plain(text="好的"), @@ -24,8 +26,27 @@ def test_inline_face_stays_in_the_same_bubble_as_text(): segments = stage._group_segment_chain(chain) - assert len(segments) == 1 - assert segments[0] == chain + assert segments == [[chain[0], chain[1]], [chain[2]]] + + +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(): @@ -38,6 +59,17 @@ def test_components_that_need_separate_sending_stay_alone(): 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]] + + @pytest.mark.asyncio async def test_log_interval_for_a_bubble_uses_total_plain_word_count(): stage = RespondStage() From 2da0ca9859a44b76898bc54b2db8afabaf712f0d Mon Sep 17 00:00:00 2001 From: Shxiao101 Date: Sat, 12 Sep 2026 17:05:30 +0900 Subject: [PATCH 4/5] refactor: treat an inline Face as glue between its neighboring text bubbles --- astrbot/core/pipeline/respond/stage.py | 33 +++++++++---------- .../test_respond_stage_segmented_reply.py | 22 +++++++++---- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 436e5de4bc..8a447557f8 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -101,29 +101,26 @@ def _group_segment_chain( ) -> list[list[BaseMessageComponent]]: """Build the bubbles sent by segmented reply. - Every Plain and every non-inline component starts a new bubble; a - Face attaches to the preceding text bubble, or to the following - Plain when the chain starts with a Face, so an inline emoji never - becomes a bubble of its own while adjacent Plain components keep - their deliberate split (#10047, #3959). + 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: - if ( - comp.type == ComponentType.Face - and segments - and segments[-1][-1].type in (ComponentType.Plain, ComponentType.Face) + 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) - continue - segments.append([comp]) - if ( - len(segments) >= 2 - and segments[0][0].type == ComponentType.Face - and segments[1][0].type == ComponentType.Plain - ): - segments[1][:0] = segments[0] - segments.pop(0) + else: + segments.append([comp]) return segments async def _calc_comp_interval( diff --git a/tests/unit/test_respond_stage_segmented_reply.py b/tests/unit/test_respond_stage_segmented_reply.py index 7eb580498d..b860fc737d 100644 --- a/tests/unit/test_respond_stage_segmented_reply.py +++ b/tests/unit/test_respond_stage_segmented_reply.py @@ -2,10 +2,10 @@ 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 attaches a Face to the preceding text bubble -(or to the following Plain when the chain starts with a Face) while adjacent -Plain components from the segmentation-words feature (#3959) keep their -deliberate split. +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 @@ -16,7 +16,7 @@ from astrbot.core.pipeline.respond.stage import RespondStage -def test_inline_face_rides_with_the_preceding_text_bubble(): +def test_inline_face_glues_the_whole_sentence_into_one_bubble(): stage = RespondStage() chain = [ Comp.Plain(text="好的"), @@ -26,7 +26,7 @@ def test_inline_face_rides_with_the_preceding_text_bubble(): segments = stage._group_segment_chain(chain) - assert segments == [[chain[0], chain[1]], [chain[2]]] + assert segments == [chain] def test_leading_face_joins_the_following_text_bubble(): @@ -70,6 +70,16 @@ def test_face_after_media_stays_its_own_bubble(): 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() From c8fa9633f10306d0f87be518b8095b3a9ec04c39 Mon Sep 17 00:00:00 2001 From: Shxiao101 Date: Sat, 12 Sep 2026 17:34:25 +0900 Subject: [PATCH 5/5] chore: re-trigger unit test CI after macOS runner flake