重构: AI聊天可靠性批次(审批累计计数/write_file diff预览/会话隔离补清/token缓存)
This commit is contained in:
@@ -111,6 +111,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
llm_concurrency: LlmConcurrency,
|
||||
max_iterations: usize,
|
||||
max_retries: usize,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone());
|
||||
@@ -168,7 +169,11 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
|
||||
for iteration in 0..max_iterations {
|
||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
|
||||
for iteration in start_iteration..max_iterations {
|
||||
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
let usage = df_ai::provider::TokenUsage {
|
||||
@@ -190,7 +195,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
|
||||
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
|
||||
{
|
||||
let session = session_arc.lock().await;
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
|
||||
tracing::warn!(
|
||||
stale_conv = %conv_id,
|
||||
@@ -199,6 +204,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
);
|
||||
return;
|
||||
}
|
||||
// F-260616-11 决策 a: 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。
|
||||
// 审批等待/达 max 暂停退出时,本字段即「当前轮+1」;审批续跑 ai_approve 读此值作
|
||||
// start_iteration 透传,实现 iteration 累计不重置(防多次审批反复跑满 max 致 token 失控)。
|
||||
session.iteration_used = iteration + 1;
|
||||
}
|
||||
|
||||
// 新一轮通知前端(第二轮起),前端需新建 assistant 消息
|
||||
@@ -210,9 +219,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
|
||||
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
|
||||
// F-260616-13: sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用
|
||||
// (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。
|
||||
let messages = {
|
||||
let session = session_arc.lock().await;
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
|
||||
let mut msgs = vec![ChatMessage::system(&system_prompt)];
|
||||
msgs.extend(history_msgs);
|
||||
@@ -248,16 +258,15 @@ pub(crate) async fn run_agentic_loop(
|
||||
|
||||
for retry_attempt in 0..=max_retries {
|
||||
// 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body)
|
||||
//
|
||||
// F-260616-13: messages 复用本轮外层构建的快照(不再持 session_arc.lock() 重建)。
|
||||
// 安全前提:本轮 stream_llm 不接收 session_arc、不 push 消息;重试期间 tool 执行
|
||||
// (process_tool_calls)在重试块之后,故重试内 session.messages 必然与外层构建时
|
||||
// 一致,重建与复用等价。收益:省去每次重试的 lock() + build_for_request(含
|
||||
// all_messages_clone 全量 clone)。
|
||||
let retry_request = CompletionRequest {
|
||||
model: provider_config.default_model.clone(),
|
||||
messages: {
|
||||
let session = session_arc.lock().await;
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
|
||||
let mut msgs = vec![ChatMessage::system(&system_prompt)];
|
||||
msgs.extend(history_msgs);
|
||||
msgs
|
||||
},
|
||||
messages: messages.clone(),
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(8192),
|
||||
stream: true,
|
||||
@@ -487,8 +496,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户
|
||||
// F-260616-03:不再 emit AiError + 走完成流程,改为 emit AiMaxRoundsReached + 保持 generating=true
|
||||
// (仿审批等待 L313-316),等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮)
|
||||
// 或点停止(ai_stop_loop → 走完成流程)。try_continue 重新 spawn run_agentic_loop,iteration 从 0 重计,
|
||||
// 故续跑天然再跑 max_iterations 轮(决策 a),无需 reset 任何计数器。
|
||||
// 或点停止(ai_stop_loop → 走完成流程)。try_continue 续跑 iteration 由调用方传 start_iteration 决定:
|
||||
// 审批续跑累计(F-260616-11 决策 a,防多次审批反复跑满 max 致 token 失控,传 session.iteration_used),
|
||||
// 达 max 续跑重计(F-260616-03 决策 a,用户点继续=授权重来,传 0 + 重置 iteration_used)。
|
||||
if !converged {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
@@ -565,7 +575,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
/// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成)
|
||||
/// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验
|
||||
/// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。
|
||||
pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState, start_iteration: usize) {
|
||||
let (is_generating, has_pending, pending_conv_id) = {
|
||||
let session = state.ai_session.lock().await;
|
||||
// pending_approvals 中任一审批的 conversation_id:审批等待态(has_pending)下作为 conv_id 来源,
|
||||
@@ -662,6 +672,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
|
||||
use super::tool_registry::generate_diff;
|
||||
|
||||
/// RiskLevel → 审计记录字符串(low/medium/high)
|
||||
pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
|
||||
@@ -274,6 +275,11 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
risk_level: risk,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
|
||||
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
|
||||
// 前端见 diff=None 时回退显新 content。
|
||||
diff: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -491,6 +497,28 @@ fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// AE-2025-03(路径 B):write_file 审批预览 diff 生成。
|
||||
///
|
||||
/// 从 write_file args 取 path(旧文件路径)+ content(新内容),
|
||||
/// 预读旧文件 → 复用 `generate_diff`(F-260615-10 LCS 行级 diff)注入审批事件。
|
||||
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None(前端回退显新 content)。
|
||||
///
|
||||
/// **仅读不改**:审批未通过前不动文件;读路径不校验(write_file handler 自身会校验
|
||||
/// validate_path,审批拒绝则 handler 不执行,恶意路径读失败仅得到 None 不产生危害)。
|
||||
async fn build_write_file_diff(args: &serde_json::Value) -> Option<String> {
|
||||
let path = args.get("path")?.as_str()?;
|
||||
let new_content = args.get("content")?.as_str()?;
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(old_content) => {
|
||||
if old_content == new_content {
|
||||
return None; // 无变化不生成 diff(理论上 write_file 不会如此,容错)
|
||||
}
|
||||
Some(generate_diff(&old_content, new_content))
|
||||
}
|
||||
Err(_) => None, // 文件不存在(新建)/ 无读权限 → 无 diff,前端显新 content
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
pub(crate) async fn process_tool_calls(
|
||||
@@ -553,6 +581,15 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
}
|
||||
pending_count += 1;
|
||||
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
|
||||
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
|
||||
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
|
||||
let approval_diff: Option<String> = if draft.name == "write_file" {
|
||||
build_write_file_diff(&args).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
@@ -560,6 +597,7 @@ pub(crate) async fn process_tool_calls(
|
||||
risk_level,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
diff: approval_diff.clone(),
|
||||
});
|
||||
session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认"));
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||
@@ -568,6 +606,7 @@ pub(crate) async fn process_tool_calls(
|
||||
name: draft.name.clone(),
|
||||
args: args.clone(),
|
||||
reason,
|
||||
diff: approval_diff,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await;
|
||||
|
||||
@@ -51,6 +51,8 @@ pub async fn ai_regenerate(
|
||||
session.generating = true;
|
||||
session.stop_flag.store(false, Ordering::SeqCst);
|
||||
session.agent_language = language.clone();
|
||||
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
|
||||
session.iteration_used = 0;
|
||||
let popped = session.messages.pop_last_assistant_round();
|
||||
if !popped {
|
||||
// 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错
|
||||
@@ -105,7 +107,7 @@ pub async fn ai_regenerate(
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0).await;
|
||||
});
|
||||
|
||||
Ok("ok".to_string())
|
||||
@@ -146,6 +148,8 @@ pub async fn ai_chat_send(
|
||||
session.generating = true;
|
||||
session.stop_flag.store(false, Ordering::SeqCst);
|
||||
session.agent_language = language.clone();
|
||||
// F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
|
||||
session.iteration_used = 0;
|
||||
session.messages.push(ChatMessage::user(&message));
|
||||
|
||||
// 首次发送时生成对话 id(懒创建:不立即落库,避免空对话残留;
|
||||
@@ -201,7 +205,7 @@ pub async fn ai_chat_send(
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0).await;
|
||||
});
|
||||
|
||||
Ok("ok".to_string())
|
||||
@@ -251,8 +255,11 @@ pub async fn ai_approve(
|
||||
}
|
||||
// 审计:拒绝(决策者=human)
|
||||
audit_finalize(&state, &tool_call_id, "rejected", None).await;
|
||||
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 session.iteration_used 透传
|
||||
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
|
||||
let start_iter = state.ai_session.lock().await.iteration_used;
|
||||
// 所有待审批处理完毕后恢复 agentic 循环
|
||||
try_continue_agent_loop(&app, &state).await;
|
||||
try_continue_agent_loop(&app, &state, start_iter).await;
|
||||
return Ok("rejected".to_string());
|
||||
}
|
||||
|
||||
@@ -301,8 +308,11 @@ pub async fn ai_approve(
|
||||
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
|
||||
}
|
||||
|
||||
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 session.iteration_used 透传
|
||||
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
|
||||
let start_iter = state.ai_session.lock().await.iteration_used;
|
||||
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
|
||||
try_continue_agent_loop(&app, &state).await;
|
||||
try_continue_agent_loop(&app, &state, start_iter).await;
|
||||
|
||||
Ok("executed".to_string())
|
||||
}
|
||||
@@ -398,6 +408,8 @@ pub async fn ai_chat_edit(
|
||||
session.generating = true;
|
||||
session.stop_flag.store(false, Ordering::SeqCst);
|
||||
session.agent_language = language.clone();
|
||||
// F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。
|
||||
session.iteration_used = 0;
|
||||
}
|
||||
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
@@ -453,6 +465,7 @@ pub async fn ai_chat_edit(
|
||||
llm_concurrency,
|
||||
max_iterations,
|
||||
max_retries,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -560,8 +573,10 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
|
||||
///
|
||||
/// 场景:run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
|
||||
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag(防上一轮残留致续跑入口即退出)
|
||||
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loop(iteration 从 0 重计,天然再跑
|
||||
/// max_iterations 轮,决策 a)。
|
||||
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loop。iteration 从 0 重计(天然再跑
|
||||
/// max_iterations 轮,决策 a),F-260616-11 落地后:重置 session.iteration_used=0 + 传
|
||||
/// start_iteration=0(达 max 续跑重计);审批续跑(ai_approve)则累计传 session.iteration_used,
|
||||
/// 两路径区分见 run_agentic_loop 达 max 分支注释。
|
||||
///
|
||||
/// 校验复用 ai_approve 模式:generating 必须为 true(暂停态)+ active_conversation_id 一致性
|
||||
/// (防陈旧 loop 续跑污染新对话)。无硬上限防无限续(决策 b:用户主动授权 = 同意烧 token)。
|
||||
@@ -581,9 +596,12 @@ pub async fn ai_continue_loop(
|
||||
}
|
||||
// 复位停止信号:暂停态可能因上一轮 stop_flag 残留为 true,续跑 loop 入口会立即退出走完成流程
|
||||
session.stop_flag.store(false, Ordering::SeqCst);
|
||||
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
|
||||
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
|
||||
session.iteration_used = 0;
|
||||
}
|
||||
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
|
||||
try_continue_agent_loop(&app, &state).await;
|
||||
try_continue_agent_loop(&app, &state, 0).await;
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
@@ -836,6 +854,11 @@ pub async fn ai_conversation_create(
|
||||
session.active_conv_created_at = Some(now);
|
||||
session.messages.clear();
|
||||
session.pending_approvals.clear();
|
||||
// F-260616-09(A 路线):补漏清字段维持单例软隔离,解「新建会话上下文残留」。
|
||||
// stop_flag 复位 false:上方生成中分支曾置 true 停旧 loop,不复位则新会话 loop
|
||||
// 启动即见 stop_flag=true 异常退出;agent_language 清空防新会话沿用旧会话语言设置。
|
||||
session.stop_flag.store(false, Ordering::SeqCst);
|
||||
session.agent_language = None;
|
||||
|
||||
Ok(serde_json::json!({ "id": id }))
|
||||
}
|
||||
|
||||
@@ -96,7 +96,9 @@ pub enum AiChatEvent {
|
||||
/// 工具调用完成
|
||||
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
|
||||
/// 需要人工审批
|
||||
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, conversation_id: Option<String> },
|
||||
/// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff),
|
||||
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
|
||||
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String> },
|
||||
/// 审批结果
|
||||
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
|
||||
/// AI 响应完成
|
||||
@@ -199,6 +201,18 @@ pub struct AiSession {
|
||||
/// `Arc<Notify>` 可 Clone(Notify 内部用 Atomic 计数,Clone 等价于 Arc 引用 +1),
|
||||
/// 不影响 AiSession 其他字段语义。
|
||||
pub notify: Arc<tokio::sync::Notify>,
|
||||
/// agentic loop 累计已跑 iteration 计数(F-260616-11 决策 a)。
|
||||
///
|
||||
/// **语义**:「下一轮起算值」。run_agentic_loop 每轮写 `iteration + 1`,审批等待/
|
||||
/// 达 max 暂停退出时即「当前轮+1」。续跑时由调用方决定如何接:
|
||||
/// - **审批续跑**(ai_approve):传本字段作 start_iteration(**累计**,防多次审批反复
|
||||
/// 跑满 max_iterations 致 token 失控);
|
||||
/// - **达 max 续跑**(ai_continue_loop):重置本字段=0 + 传 0(**重计**,F-260616-03
|
||||
/// 决策 a,用户点继续=授权重来);
|
||||
/// - **新消息首次发送**(ai_chat_send):重置本字段=0(新对话生命周期从头计数)。
|
||||
///
|
||||
/// 单例一份:当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)。
|
||||
pub iteration_used: usize,
|
||||
}
|
||||
|
||||
impl AiSession {
|
||||
@@ -213,6 +227,7 @@ impl AiSession {
|
||||
agent_language: None,
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
iteration_used: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +276,10 @@ pub struct PendingApproval {
|
||||
pub conversation_id: Option<String>,
|
||||
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
|
||||
pub recovered: bool,
|
||||
/// AE-2025-03(路径 B):write_file 的行级 unified diff(旧文件 vs 新内容)。
|
||||
/// 仅挂起审批前预读注入,供前端审批卡预览;旧文件不存在(新建)或非 write_file 为 None。
|
||||
/// recovered 审批(启动重建)无 diff(文件可能已变,重读无意义)。
|
||||
pub diff: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用草稿(流式收集时的临时结构)
|
||||
|
||||
@@ -26,7 +26,10 @@ const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
|
||||
/// 生成行级 unified diff(无外部依赖,基于 LCS)。
|
||||
/// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。
|
||||
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
|
||||
fn generate_diff(old: &str, new: &str) -> String {
|
||||
///
|
||||
/// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff,
|
||||
/// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。
|
||||
pub(crate) fn generate_diff(old: &str, new: &str) -> String {
|
||||
let a: Vec<&str> = old.lines().collect();
|
||||
let b: Vec<&str> = new.lines().collect();
|
||||
let (n, m) = (a.len(), b.len());
|
||||
|
||||
Reference in New Issue
Block a user