Skip to content

Commit 987acaf

Browse files
author
SolonCode
committed
添加Markdown代码块和表格结构保留的分段拆分逻辑
1 parent 0b64597 commit 987acaf

1 file changed

Lines changed: 112 additions & 1 deletion

File tree

soloncode-cli/src/main/java/org/noear/solon/codecli/channel/ChunkedSender.java

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)