Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions astrbot/core/pipeline/respond/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/test_respond_stage_segmented_reply.py
Original file line number Diff line number Diff line change
@@ -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")],

@EterUltimate EterUltimate Sep 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The grouped text is "hello" + "world" = "helloworld", but _word_cnt counts unspaced ASCII text as 1 word (len(text.split())), so the interval is drawn from [log10(2), log10(2)+0.5] = [0.301, 0.801] while the test asserts >= log10(3) = 0.477 — ~35% of runs fail (CI evidence: macOS job 103455275079, assert 0.47712125471966244 <= 0.30788529321914043; local repro 9/20 on Windows). Adding a trailing space makes it 2 words and the bounds deterministic:

Suggested change
[Comp.Plain(text="hello"), Comp.Face(id=277), Comp.Plain(text="world")],
[Comp.Plain(text="hello "), Comp.Face(id=277), Comp.Plain(text="world")],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good diagnosis, matches what we found. Rather than adjusting the test, the implementation now counts words per Plain component and sums them over the bubble (2da0ca9), so hello + world = 2 words and the asserted bounds are exact — no trailing-space workaround needed.

)
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
Loading