Skip to content

fix: keep inline Face components in the same bubble during segmented reply - #10049

Open
Shxiao101 wants to merge 5 commits into
AstrBotDevs:masterfrom
Shxiao101:fix/10047-segmented-inline-grouping
Open

fix: keep inline Face components in the same bubble during segmented reply#10049
Shxiao101 wants to merge 5 commits into
AstrBotDevs:masterfrom
Shxiao101:fix/10047-segmented-inline-grouping

Conversation

@Shxiao101

@Shxiao101 Shxiao101 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

With platform_settings.segmented_reply.enable: true, the respond stage sends every component of the message chain as its own message. An inline QQ Face in the middle of text (e.g. 好的[Face:277],我知道了!) is therefore split into three separate bubbles: 好的 / [Face:277] / ,我知道了!.

Fixes #10047

Modifications / 改动点

  • astrbot/core/pipeline/respond/stage.py: _group_segment_chain() now builds the segmented-reply bubbles: Plain and non-inline components start a new bubble, and a Face attaches to the preceding text bubble and glues the Plain following it into the same bubble, so an inline emoji never becomes (or creates) a bubble of its own. Adjacent Plain components without an emoji between them (segmentation-words output, feat: segment reply supports segmentation words #3959) keep their deliberate split.

  • _calc_comp_interval() accepts a bubble (a list of components) and computes the log-method interval from the Plain word counts, counted per component and summed. Single-component behavior is unchanged.

  • tests/unit/test_respond_stage_segmented_reply.py: regression tests for the glued sentence, leading Face, consecutive Plain split, Record staying alone, Face after media, glue stopping at media, and the interval calculation.

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

  • uv run pytest tests/unit — 993 passed (10 deselected locally for an unrelated environment issue that also fails on clean master)
  • ./scripts/pr_test_env.sh --profile neo (repo-recommended local check set): ruff format --check . clean (504 files), ruff check . passed, Neo critical tests 13 passed, main.py startup smoke test on http://localhost:6185 passed

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了"验证步骤"和"运行截图"

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Keep inline Faces with their surrounding text during segmented replies.

Bug Fixes:

  • Keep inline Face components and their surrounding text in a single segmented-reply bubble instead of sending the emoji and adjacent clauses separately.

Enhancements:

  • Preserve intentional splits between adjacent Plain components and keep non-inline components in separate bubbles while calculating reply intervals across grouped text.

Tests:

  • Add regression coverage for inline Face grouping, leading and media-adjacent Faces, preserved Plain splits, isolated components, and grouped interval calculation.

sourcery-ai[bot]
sourcery-ai Bot previously approved these changes Sep 11, 2026

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Approved.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@he-yufeng he-yufeng left a comment

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 grouping mechanism works for the reported shape, but including ComponentType.Plain in INLINE_SEGMENT_TYPES over-merges: any run of adjacent text components now collapses into a single bubble, which silently disables segmented reply for plain-text chains.

Concrete counterexample: the segmentation-words feature from #3959 splits LLM output at configured markers into adjacent Plain components precisely so segmented reply can deliver them as separate bubbles with intervals. With this change, ["第一段。", "第二段。"] (two adjacent Plain comps) is one inline_group and goes out as a single message, so a feature that exists to split text into separate bubbles stops splitting. The same applies to any chain where the model or an upstream stage produced more than one text component without an emoji between them.

A narrower grouping keeps the reported fix without that regression: Face attaches to the adjacent bubble (the preceding one, and to the following one only when the chain starts with a Face, so a leading emoji never flies solo), while Plain always starts a new bubble. That keeps ["好的", Face(277), ",我知道了!"] in one bubble, leaves ["第一段。", "第二段。"] as two, and preserves the segmentation-words behavior. A regression test for the consecutive-Plain case would pin it either way; right now the tests only cover chains broken up by a Face or Record, which is why the over-merge passes.

The interval change (word count over the bubble's joined text) looks right, and Record can no longer absorb a following Face since the group breaks before non-inline comps.

@EterUltimate EterUltimate left a comment

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.

Found the root cause of the macOS Run pytest suite failure (job 103455275079): the new test test_log_interval_for_a_bubble_uses_total_plain_word_count is statistically flaky, the implementation itself looks correct.

Root cause: RespondStage._word_cnt (astrbot/core/pipeline/respond/stage.py on master) uses the English branch for all-ASCII text: word_count = len(text.split()). The grouped bubble text is "hello" + "world" = "helloworld", so _word_cnt returns 1, not the 2 the test assumes. The interval is therefore random.uniform(log10(2), log10(2) + 0.5) = [0.301, 0.801], while the test asserts >= log10(3) = 0.477 — any sample below 0.477 fails (~35% of runs).

Evidence:

  • CI: Run pytest suite (macos-latest) job 103455275079 failed with assert 0.47712125471966244 <= 0.30788529321914043 (0.3079 is inside the actual [0.301, 0.801] range).
  • Local repro on Windows: 9 failures / 20 runs of this single test.

Minimal fix: add a trailing space so the grouped text is "hello world" (2 English words); the assertions then hold deterministically — see the suggestion below. Alternatively use CJK text (e.g. "你好"/"世界"), which takes the char-count branch and yields log10(5) bounds.


# "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.

@Shxiao101

Copy link
Copy Markdown
Contributor Author

Thanks for the catch — agreed that merging Plain runs silently disabled segmentation-words. Reworked in 2da0ca9:

  • Only Face is inline now: a Face attaches to the preceding bubble when that bubble ends with text (Plain/Face), and the Plain following a Face is glued back into the same bubble — so ["好的", Face(277), ",我知道了!"] goes out as the single bubble 好的[狗头],我知道了!, and a Face next to media keeps its own bubble.
  • Adjacent Plain components without an emoji between them stay separate — ["第一段。", "第二段。"] remains two bubbles, pinned by the new test_adjacent_plain_components_keep_separate_bubbles, together with tests for the leading-Face, Face-after-media, and glue-stops-at-media shapes.
  • _calc_comp_interval now counts words per Plain component and sums them over the bubble, which also fixes the flaky macOS test (same root cause EterUltimate found below).

One point I'd like to confirm: the comment mentions both "Plain always starts a new bubble" and "keeps [\"好的\", Face(277), \",我知道了!\"] in one bubble", which conflict for a mid-sentence Face — the glue reading avoids a bubble starting with ",". I went with the glue semantics; happy to switch to the strict reading if you prefer.

@sourcery-ai
sourcery-ai Bot dismissed their stale review September 12, 2026 08:34

Sourcery withdrew this approval because the latest commits introduced blocking findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 开启分段回复(segmented_reply)时,消息链中的 Face(QQ表情)会被作为独立消息切碎单独发送

3 participants