Skip to content

Commit 354dd3d

Browse files
committed
fix(ai): 审查修复——URL 丢失 /v1 404/轮询泄漏/双跑分析/过期豁免/trim_tasks 边界
1 parent 4c44297 commit 354dd3d

9 files changed

Lines changed: 156 additions & 71 deletions

File tree

‎app/src-tauri/src/ai_balance.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,11 @@ impl AiBalanceAdapter {
8181
if self.api_key.trim().is_empty() {
8282
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
8383
}
84+
// 审查修复(2026-08-21):只修剪尾斜杠,不得 trim "/v1"(同 ai_client
85+
// ——base_url 含 /v1 是 OpenAI 兼容端点约定,删掉会 404)
8486
let url = format!(
8587
"{}/user/balance",
86-
self.base_url.trim_end_matches('/').trim_end_matches("/v1")
88+
self.base_url.trim_end_matches('/')
8789
);
8890
let agent = ureq::AgentBuilder::new()
8991
.timeout(std::time::Duration::from_secs(self.timeout_secs.max(5)))

‎app/src-tauri/src/ai_client.rs‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,7 @@ impl AiClient {
104104
return Err(AiClientError::Auth("未配置 API 密钥(设置页保存或配置环境变量)".to_string()));
105105
}
106106
let payload = build_chat_payload(&self.config.model, system, user);
107-
let url = format!(
108-
"{}/chat/completions",
109-
self.config.base_url.trim_end_matches('/').trim_end_matches("/v1")
110-
);
107+
let url = chat_completions_url(&self.config.base_url);
111108
let agent = ureq::AgentBuilder::new()
112109
.timeout(std::time::Duration::from_secs(self.config.timeout_secs.max(5)))
113110
.build();
@@ -168,16 +165,24 @@ impl AiClient {
168165
///
169166
/// @ai-context: 重试仅针对 429/5xx/传输错误(幂等读操作);401/402/403
170167
/// 不重试直接归一错误;解析失败(非 JSON/结构非法)→ Parse。
171-
/// @ai-context: M1 无消费方(M2 ai_note_refine/M3 ai_enrich 采用——
172-
/// chat_json = chat_text + parse_json_object 组合),
173-
/// 登记豁免 dead_code(与 ai_guardrails V1.0 预留同模式)。
174-
#[allow(dead_code)]
168+
/// @ai-context: 由 M2 ai_note_refine / M3 ai_note_enrich 适配器消费
169+
/// (chat_json = chat_text + parse_json_object 组合)。
175170
pub fn chat_json(&self, system: &str, user: &str) -> Result<serde_json::Value, AiClientError> {
176171
let raw = self.chat_text(system, user)?;
177172
parse_json_object(&raw)
178173
}
179174
}
180175

176+
/// chat/completions 端点 URL(纯函数可单测:只修剪尾斜杠)。
177+
///
178+
/// @ai-context: 审查修复(2026-08-21):**不得** trim "/v1"——base_url 默认含
179+
/// /v1(OpenAI 兼容端点约定 https://api.siliconflow.cn/v1),
180+
/// trim_end_matches("/v1") 会把 /v1 一并删掉 → 请求打到缺 /v1
181+
/// 的路径 404,测试连接/余额/复核/精修/补充全部网络调用失败。
182+
pub fn chat_completions_url(base_url: &str) -> String {
183+
format!("{}/chat/completions", base_url.trim_end_matches('/'))
184+
}
185+
181186
/// 构建 chat/completions payload(纯函数可单测;temperature=0 + json_object)。
182187
///
183188
/// @ai-context: R1 系推理模型带 no_think=true 关闭思考标签(保 JSON 输出
@@ -212,8 +217,7 @@ pub fn extract_content(body: &str) -> Result<String, AiClientError> {
212217
///
213218
/// @ai-context: 推理模型偶发用代码块包裹 JSON——先剥围栏再 parse;
214219
/// 解析失败 → Parse 错误(调用方回退本地结果,不丢不假)。
215-
/// @ai-context: M1 仅 chat_json 引用(M2/M3 消费),登记豁免 dead_code。
216-
#[allow(dead_code)]
220+
/// 由 chat_json 消费(M2/M3 适配器链)。
217221
pub fn parse_json_object(raw: &str) -> Result<serde_json::Value, AiClientError> {
218222
let trimmed = raw.trim();
219223
let stripped = trimmed

‎app/src-tauri/src/ai_client_tests.rs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
//! ai_client.rs 单测(AAA 模式;payload/解析纯函数——网络路径不单测)。
22
33
use crate::ai_client::{
4-
build_chat_payload, extract_content, parse_json_object, AiClient, AiClientConfig, AiClientError,
4+
build_chat_payload, chat_completions_url, extract_content, parse_json_object, AiClient,
5+
AiClientConfig, AiClientError,
56
};
67
use crate::ai_settings::AiSettings;
78

9+
#[test]
10+
fn chat_url_preserves_v1_segment() {
11+
// 审查回归(2026-08-21):/v1 不得被 trim——默认端点 + 自定义端点均保留
12+
assert_eq!(
13+
chat_completions_url("https://api.siliconflow.cn/v1"),
14+
"https://api.siliconflow.cn/v1/chat/completions"
15+
);
16+
assert_eq!(
17+
chat_completions_url("https://api.siliconflow.cn/v1/"),
18+
"https://api.siliconflow.cn/v1/chat/completions"
19+
);
20+
assert_eq!(
21+
chat_completions_url("https://custom.example.com/v1"),
22+
"https://custom.example.com/v1/chat/completions"
23+
);
24+
// 用户配置裸主机(无 /v1)时按原样拼接(端点兜底由设置页提示约束)
25+
assert_eq!(
26+
chat_completions_url("https://custom.example.com"),
27+
"https://custom.example.com/chat/completions"
28+
);
29+
}
30+
831
#[test]
932
fn payload_has_expected_shape() {
1033
// temperature=0 + json_object + system/user 消息

‎app/src-tauri/src/ai_settings.rs‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,7 @@ impl AiSettings {
7777
///
7878
/// @ai-context: 失败文案携带引导(未开启→去设置页开开关;未授权→先同意
7979
/// 授权说明),供前端弹引导,不静默降级(REQ-140 验收:授权前
80-
/// 任何 AI 调用不可达)。M1 无消费方(M2 精修/M3 补充采用),
81-
/// 登记豁免 dead_code(与 ai_guardrails V1.0 预留同模式)。
82-
#[allow(dead_code)]
80+
/// 任何 AI 调用不可达)。由 M2 精修 / M3 补充命令消费。
8381
pub fn content_gate(&self) -> Result<(), String> {
8482
if !self.enabled {
8583
return Err("AI 功能未开启(设置页全局开关默认关闭)".to_string());
@@ -92,7 +90,8 @@ impl AiSettings {
9290

9391
/// 非内容类 AI 调用门控(余额查询/测试连接:仅要求开关开启——避免关闭
9492
/// 状态下误以为功能可用;测试连接为配置验证操作不 gate,见 command 层)。
95-
/// M1 无消费方(预留;与 content_gate 同登记豁免)。
93+
/// 当前无消费方(预留——若未来余额查询/测试连接要求开启开关时启用),
94+
/// 登记豁免 dead_code(与 ai_guardrails V1.0 预留同模式)。
9695
#[allow(dead_code)]
9796
pub fn enabled_gate(&self) -> Result<(), String> {
9897
if !self.enabled {

‎app/src-tauri/src/commands_ai_enrich.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ pub fn ai_enrich_estimate(
5959
) -> Result<RefineEstimateView, String> {
6060
let kinds = normalize_kinds(selected_kinds)?;
6161
let note = get_note(state.inner(), note_id)?;
62-
let chars = note.content.chars().count() + kinds.len() * 80; // 子项提示词开销
62+
// 预估 = 笔记正文 + 子项提示词开销(每子项约 80 字符的系统说明——
63+
// 保守上界,与 ai_cost 字符→token 同口径)
64+
let chars = note.content.chars().count() + kinds.len() * 80;
6365
let remember = state.ai_settings.lock().map(|s| s.remember_cost_choice).unwrap_or(false);
6466
Ok(RefineEstimateView { estimate: estimate_for_content(chars), remember_cost_choice: remember })
6567
}
@@ -190,7 +192,8 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
190192
.get_note(note_id)
191193
.map_err(|e| AiTaskFailure::Other(e.to_string()))?
192194
.ok_or_else(|| AiTaskFailure::Other("笔记不存在".to_string()))?;
193-
// 档案:关联会话档案(无则空=提示词回退讲义式)
195+
// 档案:关联会话档案(无则空=提示词回退讲义式)。DB 错误吞掉是有意
196+
// 降级(补充不因档案缺失失败——档案只是提示词参考,非必需输入)
194197
let profile = match note.session_id {
195198
Some(sid) => st
196199
.db

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,12 @@ use crate::ai_mock::AiMockAdapter;
2323
use crate::ai_note_refine::AiNoteRefineAdapter;
2424
use crate::ai_refine_protocol::AiRefineRequest;
2525
use crate::ai_task::{slice_note, AiTaskFailure, AiTaskState, SLICE_MAX_CHARS};
26-
use crate::analysis::{analyze_session_opt, SessionAnalysis};
2726
use crate::commands::AppState;
28-
use crate::commands_session_note::build_rule_draft;
27+
use crate::commands_session_note::build_rule_draft_with_analysis;
2928
use crate::note_diff::{diff_markdown, diff_stats, DiffOp};
3029
use crate::note_filter::PurifyEnv;
3130
use crate::outline::{detect_outline_smart, OutlineConfig};
32-
use crate::types::{NewNote, Note, SessionDetail};
31+
use crate::types::{NewNote, Note};
3332
use crate::video_profile::ProfileKind;
3433

3534
/// mock 模式 env 键(本地规则精修,不联网——测试/离线开发,ai_text_filter 先例)。
@@ -246,10 +245,12 @@ fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool) {
246245
corrections: st.ocr_corrections.clone(),
247246
};
248247
let outcome: Result<AiRefineResult, AiTaskFailure> = (|| {
249-
// ① 规则草稿(单一管线三出口——AI 输入基线 = 规则版输出基线)
250-
let draft = build_rule_draft(&st.db, &st.ui_junk, &env, &st.data_dir, session_id, None)
251-
.map_err(AiTaskFailure::Other)?;
252-
// ② 精修上下文(档案/章节/术语——与 apply_note_structure 同源分析)
248+
// ① 规则草稿 + 结构分析一次完成(审查修复 2026-08-21:build_rule_draft_
249+
// with_analysis 返回 analysis——章节/术语直接复用,消除二次 analyze 双跑)
250+
let (draft, analysis) =
251+
build_rule_draft_with_analysis(&st.db, &st.ui_junk, &env, &st.data_dir, session_id, None)
252+
.map_err(AiTaskFailure::Other)?;
253+
// ② 精修上下文(档案/章节/术语——analysis 已含章节边界与术语表)
253254
let session = st
254255
.db
255256
.get_session(session_id)
@@ -260,16 +261,7 @@ fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool) {
260261
.as_deref()
261262
.map(ProfileKind::parse)
262263
.unwrap_or(ProfileKind::Lecture);
263-
let segments = st.db.list_segments(session_id).map_err(|e| AiTaskFailure::Other(e.to_string()))?;
264264
let ocr_blocks = st.db.list_ocr_blocks(session_id).map_err(|e| AiTaskFailure::Other(e.to_string()))?;
265-
let detail = SessionDetail {
266-
session: session.clone(),
267-
segments,
268-
ocr_blocks: ocr_blocks.clone(),
269-
events: Vec::new(),
270-
screens: Vec::new(),
271-
};
272-
let analysis: SessionAnalysis = analyze_session_opt(&detail, kind, &env.symbol);
273265
let outline = detect_outline_smart(&ocr_blocks, &draft.ocr_screens, &OutlineConfig::default());
274266
let chapters: Vec<String> = if outline.is_empty() {
275267
analysis
@@ -352,18 +344,23 @@ pub(crate) fn set_task(st: &AppState, task_id: u64, new_state: AiTaskState) {
352344
}
353345

354346
/// 注册表容量守卫(超限丢弃最旧终态任务——防无界增长)。M3 补充任务复用。
347+
///
348+
/// @ai-context: 审查修复(2026-08-21):原实现终态任务数 < excess 时删不完
349+
/// (并行 Running 占满时 len 持续 > CAP)——改为 while 循环,
350+
/// 无终态可删时停止(Running 任务不可删——任务执行中)。
355351
pub(crate) fn trim_tasks(tasks: &mut HashMap<u64, AiTaskEntry>) {
356-
if tasks.len() <= TASKS_CAP {
357-
return;
358-
}
359-
let mut ids: Vec<u64> = tasks.iter().filter_map(|(id, t)| match t.state {
360-
AiTaskState::Pending | AiTaskState::Running { .. } => None,
361-
_ => Some(*id),
362-
}).collect();
363-
ids.sort_unstable();
364-
let excess = tasks.len() - TASKS_CAP;
365-
for id in ids.into_iter().take(excess) {
366-
tasks.remove(&id);
352+
while tasks.len() > TASKS_CAP {
353+
let oldest_terminal = tasks
354+
.iter()
355+
.filter(|(_, t)| !matches!(t.state, AiTaskState::Pending | AiTaskState::Running { .. }))
356+
.min_by_key(|(id, _)| **id)
357+
.map(|(id, _)| *id);
358+
match oldest_terminal {
359+
Some(id) => {
360+
tasks.remove(&id);
361+
}
362+
None => break,
363+
}
367364
}
368365
}
369366

‎app/src-tauri/src/commands_session_note.rs‎

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::collections::HashSet;
1616
use tauri::State;
1717

1818
use crate::ai_protocol::TextFilterDecision;
19-
use crate::analysis::analyze_session_opt;
19+
use crate::analysis::{analyze_session_opt, SessionAnalysis};
2020
use crate::commands::{normalize_title, AppState};
2121
use crate::db::Db;
2222
use crate::db_sessions::SESSION_STATUS_RECORDING;
@@ -53,25 +53,15 @@ fn load_note_material(
5353
Ok((session, segments, ocr_blocks))
5454
}
5555

56-
/// 结构渲染接线(v0.7.6 REQ-177/178,预览/落库/AI 复核三出口共用)。
57-
///
58-
/// @ai-context: 在 refresh_screen_points 之后调用——净化/配图/警示行先落定,
59-
/// 结构层在其上叠加章节标题与词汇表块;预览与落库同函数同口径
60-
/// (REQ-081 单一管线双出口契约)。
61-
/// @ai-context: 输入为会话原料(segments/ocr_blocks)——章节检测/大纲标题/
62-
/// 术语表均为纯本地规则(analysis/outline/glossary 复用)——
63-
/// 不依赖云端 AI(本地优先铁律);失败不阻断转笔记主链路
64-
/// (分析失败按空结构处理,不抛错)。
65-
/// @ai-context: pub(crate)(v0.7.6 审查修复):commands_ai.rs AI 复核出口
66-
/// 复用——复核后预览与落库同含结构层(REQ-081 三出口一致)。
67-
pub(crate) fn apply_note_structure(
68-
result: &mut NoteFilterResult,
56+
/// 结构渲染分析(纯本地:章节/术语——apply_note_structure 与
57+
/// build_rule_draft_with_analysis 共用,审查修复 2026-08-21:消除双跑)。
58+
fn analyze_for_structure(
6959
db: &Db,
7060
session: &Session,
7161
segments: &[SessionSegment],
7262
ocr_blocks: &[SessionOcrBlock],
7363
env: &PurifyEnv,
74-
) {
64+
) -> SessionAnalysis {
7565
// 档案驱动:章节检测/术语表按档案开关(网课开、口播关——analysis 内部门控)
7666
let kind = session
7767
.profile
@@ -94,7 +84,41 @@ pub(crate) fn apply_note_structure(
9484
events,
9585
screens: Vec::new(),
9686
};
97-
let analysis = analyze_session_opt(&detail, kind, &env.symbol);
87+
analyze_session_opt(&detail, kind, &env.symbol)
88+
}
89+
90+
/// 结构渲染接线(v0.7.6 REQ-177/178,预览/落库/AI 复核三出口共用)。
91+
///
92+
/// @ai-context: 在 refresh_screen_points 之后调用——净化/配图/警示行先落定,
93+
/// 结构层在其上叠加章节标题与词汇表块;预览与落库同函数同口径
94+
/// (REQ-081 单一管线双出口契约)。
95+
/// @ai-context: 输入为会话原料(segments/ocr_blocks)——章节检测/大纲标题/
96+
/// 术语表均为纯本地规则(analysis/outline/glossary 复用)——
97+
/// 不依赖云端 AI(本地优先铁律);失败不阻断转笔记主链路
98+
/// (分析失败按空结构处理,不抛错)。
99+
/// @ai-context: pub(crate)(v0.7.6 审查修复):commands_ai.rs AI 复核出口
100+
/// 复用——复核后预览与落库同含结构层(REQ-081 三出口一致)。
101+
pub(crate) fn apply_note_structure(
102+
result: &mut NoteFilterResult,
103+
db: &Db,
104+
session: &Session,
105+
segments: &[SessionSegment],
106+
ocr_blocks: &[SessionOcrBlock],
107+
env: &PurifyEnv,
108+
) {
109+
let analysis = analyze_for_structure(db, session, segments, ocr_blocks, env);
110+
apply_note_structure_with_analysis(result, session, ocr_blocks, env, &analysis);
111+
}
112+
113+
/// 结构渲染接线(复用外部分析结果——精修任务避免双跑 analyze,审查修复
114+
/// 2026-08-21:build_rule_draft_with_analysis 提供一次分析供结构层 + 任务共用)。
115+
pub(crate) fn apply_note_structure_with_analysis(
116+
result: &mut NoteFilterResult,
117+
_session: &Session,
118+
ocr_blocks: &[SessionOcrBlock],
119+
env: &PurifyEnv,
120+
analysis: &SessionAnalysis,
121+
) {
98122
let outline = detect_outline_smart(ocr_blocks, &result.ocr_screens, &OutlineConfig::default());
99123
let _ = render_note_structure(
100124
result,
@@ -119,6 +143,19 @@ pub(crate) fn build_rule_draft(
119143
id: i64,
120144
title: Option<String>,
121145
) -> Result<NoteFilterResult, String> {
146+
Ok(build_rule_draft_with_analysis(db, ui_junk, env, data_dir, id, title)?.0)
147+
}
148+
149+
/// 构建规则草稿 + 结构分析(审查修复 2026-08-21:分析与结构渲染一次完成并
150+
/// 返回 analysis——AI 精修任务直接复用章节/术语,消除二次 analyze 双跑)。
151+
pub(crate) fn build_rule_draft_with_analysis(
152+
db: &Db,
153+
ui_junk: &UiJunkList,
154+
env: &PurifyEnv,
155+
data_dir: &std::path::Path,
156+
id: i64,
157+
title: Option<String>,
158+
) -> Result<(NoteFilterResult, SessionAnalysis), String> {
122159
let (session, segments, ocr_blocks) = load_note_material(db, id)?;
123160
let fallback = format!("{}(会话)", session.title);
124161
let title = normalize_title(title.unwrap_or_default(), &fallback);
@@ -132,8 +169,9 @@ pub(crate) fn build_rule_draft(
132169
crate::note_filter::refresh_screen_points(&mut result);
133170
// v0.7.6(REQ-177/178):结构渲染——章节标题 + 词汇表块(纯本地增强层;
134171
// 无结构数据/分析失败 → 原样输出不阻断,见 apply_note_structure)
135-
apply_note_structure(&mut result, db, &session, &segments, &ocr_blocks, env);
136-
Ok(result)
172+
let analysis = analyze_for_structure(db, &session, &segments, &ocr_blocks, env);
173+
apply_note_structure_with_analysis(&mut result, &session, &ocr_blocks, env, &analysis);
174+
Ok((result, analysis))
137175
}
138176

139177
/// 会话 → 笔记核心(v0.7.1 提取:单条与批量共用同一管线)。

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
7272
};
7373
}, [taskId]);
7474

75+
// 审查修复(2026-08-21):组件卸载时停止轮询——否则 interval 持续 invoke
76+
// 并对已卸载组件 setState(切会话/关面板后泄漏)
77+
useEffect(() => {
78+
return () => {
79+
if (polling.current) {
80+
clearInterval(polling.current);
81+
polling.current = null;
82+
}
83+
};
84+
}, []);
85+
7586
useEffect(() => {
7687
void invoke<AiSettingsView>("ai_get_settings").then(setSettings).catch(() => undefined);
7788
}, []);

0 commit comments

Comments
 (0)