@@ -100,6 +100,7 @@ public SendResult(int totalParts, int failedParts) {
100100 * <p>如果全文不超过 maxLen,直接发送并返回。超过则分段:
101101 * <ol>
102102 * <li>每段 maxLen 字符,从第 2 段起标注 "(2)" "(3)" 前缀</li>
103+ * <li>拆分时保留 Markdown 结构完整性:不在代码块内、不在表格内截断</li>
103104 * <li>每段发送前先等待 intervalMs(第一段不等待)</li>
104105 * <li>发送失败时按指数退避重试最多 maxRetries 次</li>
105106 * </ol>
@@ -125,7 +126,12 @@ public static SendResult sendChunked(String text, Config cfg, SendFunc func) {
125126 int part = 1 ;
126127
127128 while (pos < text .length ()) {
128- int end = Math .min (pos + cfg .getMaxLen (), text .length ());
129+ int end = findSplitPoint (text , pos , cfg .getMaxLen ());
130+ if (end <= pos ) {
131+ // 极端情况保护(理论上不应触发)
132+ end = Math .min (pos + cfg .getMaxLen (), text .length ());
133+ }
134+
129135 String chunk = text .substring (pos , end );
130136
131137 // 从第 2 段起加序号前缀
@@ -160,6 +166,111 @@ public static SendResult sendChunked(String text, Config cfg, SendFunc func) {
160166 return new SendResult (totalParts , failedParts );
161167 }
162168
169+ /**
170+ * 在文本中查找安全拆分点,保留 Markdown 块级结构完整性。
171+ *
172+ * <p>从 start 开始扫描,在不超过 start+maxLen 的范围内找到最远的
173+ * <b>不在代码块内且不在表格内</b> 的行尾作为拆分点。</p>
174+ *
175+ * <p>拆分优先级:
176+ * <ol>
177+ * <li>不在代码块/表格内的行尾 — 安全(最佳)</li>
178+ * <li>任何行尾 — 可接受(折中)</li>
179+ * <li>硬截断到 maxLen — 最后手段</li>
180+ * </ol>
181+ *
182+ * @param text 完整文本
183+ * @param start 起始位置(从该位置开始扫描)
184+ * @param maxLen 每段最大字符数
185+ * @return 拆分位置索引(不含该位置,返回的长度 ≤ start + maxLen)
186+ */
187+ private static int findSplitPoint (String text , int start , int maxLen ) {
188+ int limit = Math .min (start + maxLen , text .length ());
189+ if (limit >= text .length ()) {
190+ return text .length ();
191+ }
192+
193+ int lastSafe = -1 ; // 最近的安全拆分点(不在代码块/表格内)
194+ int lastLineEnd = -1 ; // 最近的行尾(任何环境)
195+
196+ boolean inCodeBlock = false ;
197+ boolean inTable = false ;
198+ boolean tableHeaderSeen = false ; // 上一行是否是表头行
199+ int lineStart = start ;
200+
201+ for (int i = start ; i < text .length (); i ++) {
202+ char c = text .charAt (i );
203+
204+ if (c == '\n' ) {
205+ int lineEnd = i + 1 ; // 包含换行符
206+ String line = text .substring (lineStart , i );
207+ String trimmed = line .trim ();
208+
209+ // ---- 代码块状态跟踪(fenced ``` 或 ~~~) ----
210+ if (!inCodeBlock ) {
211+ if (trimmed .startsWith ("```" ) || trimmed .startsWith ("~~~" )) {
212+ inCodeBlock = true ;
213+ }
214+ } else {
215+ if (trimmed .startsWith ("```" ) || trimmed .startsWith ("~~~" )) {
216+ inCodeBlock = false ;
217+ }
218+ }
219+
220+ // ---- 表格状态跟踪(仅当不在代码块内) ----
221+ if (!inCodeBlock ) {
222+ boolean isTableLine = isTableRow (trimmed );
223+
224+ if (trimmed .isEmpty ()) {
225+ inTable = false ;
226+ tableHeaderSeen = false ;
227+ } else if (isTableLine ) {
228+ if (inTable || tableHeaderSeen ) {
229+ inTable = true ;
230+ }
231+ tableHeaderSeen = true ;
232+ } else {
233+ inTable = false ;
234+ tableHeaderSeen = false ;
235+ }
236+ }
237+
238+ // 记录安全位置
239+ if (!inCodeBlock && !inTable ) {
240+ lastSafe = lineEnd ;
241+ }
242+ lastLineEnd = lineEnd ;
243+
244+ lineStart = i + 1 ;
245+
246+ if (lineEnd > limit ) {
247+ // 已超过限制,返回最佳候选
248+ if (lastSafe > start ) {
249+ return lastSafe ;
250+ }
251+ if (lastLineEnd > start ) {
252+ return lastLineEnd ;
253+ }
254+ // 单行超长,只能硬截断
255+ return limit ;
256+ }
257+ }
258+ }
259+
260+ // 扫描完仍未超限
261+ return text .length ();
262+ }
263+
264+ /**
265+ * 判断一行是否为 Markdown 表格行(以 | 开头和结尾,且至少 2 个 |)
266+ */
267+ private static boolean isTableRow (String line ) {
268+ if (line == null || line .isEmpty ()) return false ;
269+ if (!line .startsWith ("|" ) || !line .endsWith ("|" )) return false ;
270+ long pipeCount = line .chars ().filter (ch -> ch == '|' ).count ();
271+ return pipeCount >= 2 ;
272+ }
273+
163274 /**
164275 * 带指数退避重试的发送
165276 */
0 commit comments