修复: loop_state 懒创建继承+入口早退清理(#10), update_full 剥离列封 lost-update(#11), MidStream 保文不回填半截 reasoning, 标题剥标签 sanitize(#9), ANSI 源头抑制+strip 兜底(#8)
This commit is contained in:
@@ -169,10 +169,95 @@ pub fn current_shell() -> ShellType {
|
||||
ShellType::default()
|
||||
}
|
||||
|
||||
/// 剥离 ANSI 转义序列(#8 出口兜底)。
|
||||
///
|
||||
/// 源头抑制(build_command 注入 NO_COLOR/TERM=dumb)覆盖主流工具链,但总有漏网
|
||||
/// (不读环境变量的工具/嵌套脚本转发色码)。本纯函数兜底 CSI/OSC 两类常见序列:
|
||||
/// - CSI:ESC [ 参数字母(颜色/光标/擦除,如 \x1b[7m \x1b[0K)
|
||||
/// - OSC:ESC ] ... BEL 或 ESC \(终端标题/超链接,罕见但同样乱码)
|
||||
/// 不碰普通文本与换行,幂等(已剥离文本再剥无变化)。
|
||||
pub fn strip_ansi(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c != '\x1b' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
// c == ESC:按下一字符分派序列类型
|
||||
match text[i + 1..].chars().next() {
|
||||
Some('[') => {
|
||||
// CSI:跳到终止字母(@-~)为止
|
||||
let rest = &text[i + 2..];
|
||||
match rest.find(|ch: char| ('\u{40}'..='\u{7e}').contains(&ch)) {
|
||||
Some(end) => {
|
||||
// 跳过整个序列: advance 到 i+2+end 的下一字符
|
||||
let skip = 2 + end + 1;
|
||||
for _ in 1..skip {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 流式截断的残缺序列(无终止符):丢弃剩余全部
|
||||
for _ in (i + 1)..text.len() {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(']') => {
|
||||
// OSC:到 BEL(\x07) 或 ST(ESC \)为止
|
||||
let rest = &text[i + 2..];
|
||||
let bel = rest.find('\x07');
|
||||
let st = rest.find("\x1b\\");
|
||||
match (bel, st) {
|
||||
(Some(b), None) => {
|
||||
// 已消费 ESC,再消费 i+1..=i+2+b(含 BEL)共 b+2 个
|
||||
for _ in 0..(2 + b) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
(_, Some(s)) => {
|
||||
// 再消费 i+1..=i+2+s+1(含 ST 两字符)共 s+3 个
|
||||
for _ in 0..(2 + s + 1) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
for _ in (i + 1)..text.len() {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 两字符转义(ESC \ ESC ( 等):丢 ESC 保留后字符?保守双丢后单字符
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #8 ANSI 剥离:CSI 色码/擦除/流式残缺序列/OSC/普通文本不动
|
||||
#[test]
|
||||
fn strip_ansi_basic() {
|
||||
assert_eq!(strip_ansi("\x1b[7ma\x1b[0mb"), "ab");
|
||||
assert_eq!(strip_ansi("\x1b[2K\x1b[1Gdone"), "done");
|
||||
assert_eq!(strip_ansi("plain text\nline2"), "plain text\nline2");
|
||||
// 流式残缺(无终止符):丢残序列
|
||||
assert_eq!(strip_ansi("ok\x1b[3"), "ok");
|
||||
// OSC BEL 终止
|
||||
assert_eq!(strip_ansi("\x1b]0;title\x07body"), "body");
|
||||
// 幂等
|
||||
let once = strip_ansi("\x1b[32mgreen\x1b[0m");
|
||||
assert_eq!(strip_ansi(&once), once);
|
||||
}
|
||||
|
||||
/// 死缓存回归测试:probe_pwsh 写入后,Default 同步路径必须读到同一缓存值。
|
||||
///
|
||||
/// 历史 bug:probe_pwsh_cached() 与 probe_pwsh() 各持独立 OnceLock,Default 读的永空。
|
||||
@@ -312,6 +397,13 @@ fn build_command(request: ShellRequest) -> tokio::process::Command {
|
||||
for (key, value) in &request.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
// #8 ANSI 裸露根治(源头抑制):非交互管道下多数 CLI 仍探测 TERM 发色码,cargo/ls 等
|
||||
// 输出 \x1b[Nm 原样透传前端显示为 ␛[7m 乱码。注入 NO_COLOR(https://no-color.org 标准)+
|
||||
// TERM=dumb+FORCE_COLOR=0 三重信号,主流工具链(cargo/npm/git/ls/pytest)管道下停发色码。
|
||||
// 调用方显式传同名 env 时以调用方为准(后写覆盖,见上循环顺序)。
|
||||
cmd.env("NO_COLOR", "1");
|
||||
cmd.env("TERM", "dumb");
|
||||
cmd.env("FORCE_COLOR", "0");
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -346,8 +438,8 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
let duration = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(ShellResult {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
stdout: strip_ansi(&String::from_utf8_lossy(&output.stdout)),
|
||||
stderr: strip_ansi(&String::from_utf8_lossy(&output.stderr)),
|
||||
exit_code: output.status.code(),
|
||||
duration_ms: duration,
|
||||
})
|
||||
@@ -430,21 +522,23 @@ where
|
||||
let status = status?;
|
||||
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
|
||||
while let Some((kind, line)) = rx.recv().await {
|
||||
let clean = strip_ansi(&line);
|
||||
match kind {
|
||||
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
|
||||
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
|
||||
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
|
||||
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
|
||||
}
|
||||
on_output(kind, &line);
|
||||
on_output(kind, &clean);
|
||||
}
|
||||
break status;
|
||||
}
|
||||
// 收到一行 → 累积 + 回调
|
||||
Some((kind, line)) = rx.recv() => {
|
||||
let clean = strip_ansi(&line);
|
||||
match kind {
|
||||
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
|
||||
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
|
||||
StreamKind::Stdout => { stdout_buf.push_str(&clean); stdout_buf.push('\n'); }
|
||||
StreamKind::Stderr => { stderr_buf.push_str(&clean); stderr_buf.push('\n'); }
|
||||
}
|
||||
on_output(kind, &line);
|
||||
on_output(kind, &clean);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,13 +199,17 @@ impl_repo!(
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
||||
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含
|
||||
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, loop_state = ?14 WHERE id = ?15",
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
|
||||
params![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.loop_state, rec.id
|
||||
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user