Skip to content

Commit c7e29e6

Browse files
committed
fix(ai): 审查问题全量修复——Ollama 门禁/预设单源/密钥错配等(v0.11.6 M1)
1 parent 464533d commit c7e29e6

10 files changed

Lines changed: 104 additions & 35 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ impl std::fmt::Display for AiClientError {
8686
impl AiClientError {
8787
/// 是否可触发 Provider 降级(Network/Server/Quota 瞬态;Auth/Balance/
8888
/// Parse 归因用户或响应——不降级,错误原样上抛引导)。
89+
/// m-1:M3 任务层降级接线(v0.11.6.md 遗留登记)——纯函数已有单测,保留待接线。
90+
#[allow(dead_code)]
8991
pub fn is_fallbackable(&self) -> bool {
9092
matches!(
9193
self,
@@ -319,6 +321,8 @@ fn env_parse(key: &str, default: u64) -> u64 {
319321
/// 降级链 Provider id 序列(纯函数):默认 Provider + 其 fallback_order 中
320322
/// 存在且启用的 id(去重;M1 简单版:只解析顺序,实际 fallback 调用由
321323
/// 任务层接线——最多 fallback 一次)。
324+
/// m-1:M3 任务层降级接线(v0.11.6.md 遗留登记)——纯函数已有单测,保留待接线。
325+
#[allow(dead_code)]
322326
pub fn fallback_provider_ids(store: &AiProviderStore, primary: &AiProviderConfig) -> Vec<String> {
323327
let mut ids = vec![primary.id.clone()];
324328
for id in &primary.fallback_order {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ pub async fn ai_enrich_start(
8484
}
8585
let mock = std::env::var(MOCK_ENV).map(|v| v == "1").unwrap_or(false);
8686
if !mock {
87-
let has_key = crate::commands_ai_providers::resolve_default_provider_key(&st)?.is_some();
88-
if !has_key {
87+
let ready = crate::commands_ai_providers::default_provider_ready(&st)?;
88+
if !ready {
8989
return Err("未配置 API 密钥(请在设置页 AI 服务提供商中配置)".to_string());
9090
}
9191
}
@@ -274,10 +274,11 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
274274
};
275275
let settings = st.ai_settings.lock().map_err(|e| AiTaskFailure::Other(e.to_string()))?.clone();
276276
let store = st.ai_providers.lock().map_err(|e| AiTaskFailure::Other(format!("AI Provider 存储锁中毒: {}", e)))?.clone();
277-
// M1 统一解析口:env 优先 > 默认 Provider per-provider 凭据 > 旧 default scope
277+
// m-2:密钥解析错误统一传播(原 .ok().flatten() 静默吞错)
278278
let client = AiClient::from_settings_with_store(
279279
&settings,
280-
crate::commands_ai_providers::resolve_default_provider_key(&st).ok().flatten(),
280+
crate::commands_ai_providers::resolve_default_provider_key(&st)
281+
.map_err(|e| AiTaskFailure::Other(e))?,
281282
&store,
282283
);
283284
let adapter = AiNoteEnrichAdapter::new(client.clone());

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@
1010
//! 精修/补充/余额 4 个旧调用点统一改走此口——Provider 面板
1111
//! 保存的密钥对实际 AI 调用生效(Task 4 审查 Important 修复)。
1212
13+
use std::sync::atomic::{AtomicU64, Ordering};
14+
1315
use tauri::State;
1416

1517
use crate::ai_provider::{preset_templates, provider_scope, AiProviderConfig};
1618
use crate::commands::AppState;
1719

20+
/// Provider id 生成计数器(同秒碰撞防御)。
21+
static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
22+
1823
/// Provider 视图(前端展示;密钥只暴露存在性与来源——明文红线)。
1924
#[derive(Debug, Clone, serde::Serialize)]
2025
#[serde(rename_all = "camelCase")]
@@ -88,9 +93,15 @@ pub fn ai_provider_list(state: State<'_, AppState>) -> Result<Vec<AiProviderView
8893
#[tauri::command]
8994
pub fn ai_provider_add(state: State<'_, AppState>, input: AiProviderInput) -> Result<AiProviderView, String> {
9095
let mut provider = resolve_input(&input)?;
96+
let now = crate::db_sessions_rows::unix_seconds();
97+
let seq = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
9198
provider.id = input.id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| {
92-
format!("p-{}", crate::db_sessions_rows::unix_seconds())
99+
format!("p-{}-{}", now, seq)
93100
});
101+
// m-3:id 字符白名单校验(防 scope 文件路径注入碰撞)
102+
if !provider.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
103+
return Err("Provider id 仅允许字母/数字/连字符/下划线".to_string());
104+
}
94105
provider.validate()?;
95106
let mut store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?;
96107
// 持锁跨 save_key + save:保证凭据与配置写入原子性(凭据先写——
@@ -134,7 +145,9 @@ pub fn ai_provider_remove(state: State<'_, AppState>, id: String) -> Result<(),
134145
return Err("默认 Provider 不能删除——请先设置其他默认".to_string());
135146
}
136147
store.providers.retain(|p| p.id != id);
137-
let _ = state.ai_credentials.clear_key(&provider_scope(&id));
148+
if let Err(e) = state.ai_credentials.clear_key(&provider_scope(&id)) {
149+
eprintln!("[AiProvider] 删除 Provider {} 凭据失败: {}", id, e);
150+
}
138151
store.save(&state.ai_providers_path)
139152
}
140153

@@ -183,6 +196,10 @@ pub fn ai_provider_test(state: State<'_, AppState>, id: String) -> Result<String
183196
return Err("请先选择默认模型".to_string());
184197
}
185198
let api_key = state.ai_credentials.load_key(&provider_scope(&id))?.unwrap_or_default();
199+
// m-7.1:非 Ollama Provider 无密钥时明确提示
200+
if api_key.is_empty() && provider.kind != crate::ai_provider::ProviderKind::Ollama {
201+
return Err("该 Provider 未配置密钥——请先配置密钥再测试连接".to_string());
202+
}
186203
let client = crate::ai_client::AiClient::from_provider(&provider, Some(api_key));
187204
let reply = client
188205
.chat_text(
@@ -214,6 +231,41 @@ pub fn resolve_default_provider_key(state: &AppState) -> Result<Option<String>,
214231
}
215232
}
216233

234+
/// 默认 Provider 是否就绪(有密钥 或 本地免密钥端点——Ollama 本地推理
235+
/// 无需 API 密钥,门禁据此放行;本地优先叙事)。
236+
pub fn default_provider_ready(state: &AppState) -> Result<bool, String> {
237+
let default_id = {
238+
let store = state.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?;
239+
store.effective_default_id()
240+
};
241+
if let Some(id) = default_id {
242+
let is_local = state
243+
.ai_providers
244+
.lock()
245+
.map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?
246+
.get(&id)
247+
.map(|p| p.kind == crate::ai_provider::ProviderKind::Ollama)
248+
.unwrap_or(false);
249+
if is_local {
250+
return Ok(true);
251+
}
252+
}
253+
Ok(resolve_default_provider_key(state)?.is_some())
254+
}
255+
256+
/// 默认 Provider 是否为本地(Ollama——无计费语义)。
257+
pub fn is_default_provider_local(state: &AppState) -> bool {
258+
let store = state.ai_providers.lock();
259+
store
260+
.ok()
261+
.and_then(|s| {
262+
let id = s.effective_default_id()?;
263+
let p = s.get(&id)?;
264+
Some(p.kind == crate::ai_provider::ProviderKind::Ollama)
265+
})
266+
.unwrap_or(false)
267+
}
268+
217269
/// 入参 → 配置(kind 字符串解析 + 公共字段映射;id 由调用方决定)。
218270
fn resolve_input(input: &AiProviderInput) -> Result<AiProviderConfig, String> {
219271
let kind = match input.kind.as_str() {

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ pub async fn ai_refine_start(
133133
let mock = std::env::var(MOCK_ENV).map(|v| v == "1").unwrap_or(false);
134134
// ② 密钥解析(env > 凭据库);非 mock 且无密钥 → 明确错误(不创建任务)
135135
if !mock {
136-
let has_key = crate::commands_ai_providers::resolve_default_provider_key(&st)?.is_some();
137-
if !has_key {
136+
let ready = crate::commands_ai_providers::default_provider_ready(&st)?;
137+
if !ready {
138138
return Err("未配置 API 密钥(请在设置页 AI 服务提供商中配置)".to_string());
139139
}
140140
}
@@ -489,14 +489,14 @@ pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Re
489489
}
490490
let required = est.est_cost_yuan * BALANCE_SAFETY_FACTOR;
491491
// 余额查询(短超时——余额接口抖动不阻断精修;失败放行宽容降级)
492-
// M1 统一解析口:env 优先 > 默认 Provider per-provider 凭据 > 旧 default scope
493-
let api_key = crate::commands_ai_providers::resolve_default_provider_key(st)
494-
.ok()
495-
.flatten()
496-
.unwrap_or_default();
497-
if api_key.is_empty() {
498-
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
492+
// M1 统一门禁:Ollama 本地 Provider 无计费语义——跳过余额检查(m-7.3)
493+
if crate::commands_ai_providers::is_default_provider_local(st) {
494+
return Ok(());
499495
}
496+
if !crate::commands_ai_providers::default_provider_ready(st)? {
497+
return Err("未配置 API 密钥——请在设置页「AI 服务提供商」配置密钥(或使用 Ollama 本地)".to_string());
498+
}
499+
let api_key = crate::commands_ai_providers::resolve_default_provider_key(st)?.unwrap_or_default();
500500
let settings = st.ai_settings.lock().map_err(|e| format!("AI 设置锁中毒: {}", e))?.clone();
501501
let store = st.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?.clone();
502502
let cfg = crate::ai_client::AiClient::from_settings_with_store(&settings, Some(api_key), &store).config;

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,11 @@ pub struct BalanceView {
4848
pub fn ai_get_settings(state: State<'_, AppState>) -> Result<AiSettingsView, String> {
4949
let s = snapshot_settings(&state)?;
5050
let env_key = env_api_key();
51-
// v0.11.6 M1:密钥存在性走统一解析口(env > 默认 Provider per-provider >
52-
// 旧 default scope)——Provider 面板配置的密钥对前端门禁同样生效
53-
let stored = if env_key.is_none() {
54-
crate::commands_ai_providers::resolve_default_provider_key(&state)?
55-
} else {
56-
None
57-
};
51+
// v0.11.6 M1 code-review 修复:统一走 default_provider_ready(Ollama 本地
52+
// 无需密钥视为已就绪;env 优先展示语义保留)
5853
let (has_key, key_source) = if env_key.is_some() {
5954
(true, "env".to_string())
60-
} else if stored.is_some() {
55+
} else if crate::commands_ai_providers::default_provider_ready(&state)? {
6156
(true, "credential".to_string())
6257
} else {
6358
(false, "none".to_string())

‎app/src/components/AiProviderSettings.tsx‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* DeepSeek/OpenRouter/Ollama/自定义)。密钥只写不回传——视图仅
77
* 显示"已配置";测试连接走 ai_provider_test(最小 chat 请求)。
88
*
9-
* @line-limit-exemption: 338 行(300 上限)——v0.11.6 M1 code-review 修复
10-
* (确认弹窗/内联密钥输入/textarea 模型列表)后越线,
9+
* @line-limit-exemption: 329 行(300 上限)——v0.11.6 M1 code-review 修复
10+
* (确认弹窗/内联密钥输入/presetOptions/textarea 模型列表)后越线,
1111
* 已登记 docs/standards/line-limit-exemptions.md。
1212
*/
1313
import { useEffect, useState } from "react";
@@ -48,6 +48,8 @@ export default function AiProviderSettings() {
4848
const [showAdd, setShowAdd] = useState(false);
4949
const [keyInputId, setKeyInputId] = useState<string | null>(null);
5050
const [keyInput, setKeyInput] = useState("");
51+
// I-3:预设双源——后端 ai_provider_presets 优先,失败回退内置常量
52+
const [presetOptions, setPresetOptions] = useState(PRESET_OPTIONS);
5153

5254
useEffect(() => {
5355
void load();
@@ -60,6 +62,11 @@ export default function AiProviderSettings() {
6062
} catch (e) {
6163
setMsg({ kind: "err", text: `读取 Provider 列表失败:${e}` });
6264
}
65+
// I-3:预设双源——从后端拉取预设模板,失败保持内置常量兜底
66+
try {
67+
const presets = await invoke<AiProviderView[]>("ai_provider_presets");
68+
setPresetOptions(presets.map((p) => ({ kind: p.kind as AiProviderInput["kind"], name: p.name, baseUrl: p.baseUrl, models: p.models[0] ?? "" })));
69+
} catch { /* 保持内置常量兜底 */ }
6370
};
6471

6572
const run = async (op: () => Promise<unknown>, okText?: string) => {
@@ -147,7 +154,7 @@ export default function AiProviderSettings() {
147154
{p.hasKey ? (
148155
<button style={btn} onClick={() => void clearKey(p.id)} disabled={busy}>清除密钥</button>
149156
) : (
150-
<button style={btn} onClick={() => setKeyInputId(p.id)} disabled={busy}>配置密钥</button>
157+
<button style={btn} onClick={() => { setKeyInputId(p.id); setKeyInput(""); }} disabled={busy}>配置密钥</button>
151158
)}
152159
{!p.isDefault && (
153160
<button style={{ ...btn, color: "#dc2626" }} onClick={() => void removeProvider(p.id)} disabled={busy}>删除</button>
@@ -166,9 +173,11 @@ export default function AiProviderSettings() {
166173
style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }}
167174
disabled={busy}
168175
onClick={() => {
169-
void run(() => invoke("ai_provider_save_key", { id: p.id, apiKey: keyInput.trim() }), "密钥已保存");
170-
setKeyInput("");
171-
setKeyInputId(null);
176+
void run(async () => {
177+
await invoke("ai_provider_save_key", { id: p.id, apiKey: keyInput.trim() });
178+
setKeyInput("");
179+
setKeyInputId(null);
180+
}, "密钥已保存");
172181
}}
173182
>
174183
保存
@@ -189,6 +198,7 @@ export default function AiProviderSettings() {
189198

190199
{showAdd && (
191200
<ProviderForm
201+
presetOptions={presetOptions}
192202
onCancel={() => setShowAdd(false)}
193203
onDone={(text) => {
194204
setShowAdd(false);
@@ -201,6 +211,7 @@ export default function AiProviderSettings() {
201211

202212
{editing && (
203213
<ProviderForm
214+
presetOptions={presetOptions}
204215
initial={editing}
205216
onCancel={() => setEditing(null)}
206217
onDone={(text) => {
@@ -227,13 +238,15 @@ function ProviderForm({
227238
onCancel,
228239
onDone,
229240
onError,
241+
presetOptions = PRESET_OPTIONS,
230242
}: {
231243
initial?: AiProviderView;
232244
onCancel: () => void;
233245
onDone: (text: string) => void;
234246
onError: (text: string) => void;
247+
presetOptions: { kind: AiProviderInput["kind"]; name: string; baseUrl: string; models: string }[];
235248
}) {
236-
const [preset, setPreset] = useState(PRESET_OPTIONS[0]);
249+
const [preset, setPreset] = useState(presetOptions[0]);
237250
const [name, setName] = useState(initial?.name ?? "");
238251
const [baseUrl, setBaseUrl] = useState(initial?.baseUrl ?? "");
239252
const [models, setModels] = useState(initial?.models.join("\n") ?? "");
@@ -242,7 +255,7 @@ function ProviderForm({
242255
const [busy, setBusy] = useState(false);
243256

244257
const applyPreset = (i: number) => {
245-
const p = PRESET_OPTIONS[i];
258+
const p = presetOptions[i];
246259
setPreset(p);
247260
if (!initial) {
248261
setName(p.name);
@@ -291,7 +304,7 @@ function ProviderForm({
291304
{!initial && (
292305
<div style={{ marginBottom: 8 }}>
293306
<span style={{ marginRight: 6 }}>模板:</span>
294-
{PRESET_OPTIONS.map((p, i) => (
307+
{presetOptions.map((p, i) => (
295308
<button
296309
key={p.name}
297310
style={{ ...btn, marginRight: 4, background: preset.name === p.name ? "#0d9488" : "#fff", color: preset.name === p.name ? "#fff" : "#1f2937", border: "none" }}

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
140140
setResult(null);
141141
setFailure(null);
142142
setProgress(null);
143+
setEstimate(null);
144+
setBalance(null);
143145
};
144146

145147
const est = estimate?.estimate;

‎app/src/components/EnrichPanel.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ export default function EnrichPanel({ noteId, onUpdated }: { noteId: number; onU
192192
setResult(null);
193193
setFailure(null);
194194
setProgress(null);
195+
setEstimate(null);
196+
setBalance(null);
195197
};
196198

197199
const est = estimate?.estimate;

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
| app/src-tauri/src/analysis_tests.rs | 341 | 分析编排测试域(档案门控矩阵 + REQ-108 事件消费 + M2 三字段)单模块 #[path] 挂载 | 若再增长:事件消费组拆至 analysis_events_tests.rs |
4646
| app/src-tauri/src/streaming_asr_tests.rs | 340 | 流式 ASR 测试域(端点处理/静音判定/段切分回归)单模块 #[path] 挂载 | 若再增长:端点处理组拆至 streaming_endpoint_tests.rs |
4747
| app/src-tauri/src/commands_ai.rs | 340 | v0.5.0 起 AI 复核命令域(边界批量复核/配额/缓存/审计 + 结构渲染接线);与 note_filter_ai(纯逻辑)分层 | 若再增长:批量复核循环拆至 commands_ai_review.rs |
48-
| app/src-tauri/src/ai_client.rs | 318 | v0.11.6 M1(AiClient::from_provider / from_settings_with_store / is_fallbackable / fallback_provider_ids)——Provider 解析与错误分类内聚于 AiClient 域,构造入口与降级链纯函数同文件便于单测 | 若再增长:fallback_provider_ids 拆至 ai_fallback.rs |
48+
| app/src-tauri/src/ai_client.rs | 322 | v0.11.6 M1(AiClient::from_provider / from_settings_with_store / is_fallbackable / fallback_provider_ids + code-review m-1 dead_code 豁免注释)——Provider 解析与错误分类内聚于 AiClient 域,构造入口与降级链纯函数同文件便于单测 | 若再增长:fallback_provider_ids 拆至 ai_fallback.rs |
4949
| app/src-tauri/src/screens.rs | 332 | v0.7.3(REQ-155/156/160):画面要点屏构建编排(分组/聚类/图匹配 IO)+ 可消费块过滤扩展——编排与纯函数分层(纯函数在 screen_merge.rs) | 若再增长:filter_usable_blocks 拆至 screen_filter.rs |
5050
| app/src-tauri/src/ocr_cache.rs | 329 | OCR 结果缓存域(内容指纹键/容量淘汰/命中率统计)内聚;缓存策略与指纹算法共享上下文 | 若再增长:指纹算法拆至 ocr_fingerprint.rs |
5151
| app/src-tauri/src/ai_refine_task.rs | 322 | v0.8.0 F2-B4 拆分产物:精修任务执行域(任务编排/并发切片 worker 池/单片重试/部分成功/审计/落库)——并发编排与状态流转内聚 | 若再增长:refine_slices_concurrent 拆至 ai_refine_task_workers.rs |
@@ -67,7 +67,7 @@
6767
| app/src/components/ProfileDetector.tsx | 330 | 档案检测组件:投票/确认流/记忆偏好 UI + v0.11.5 Task 5 冲突提示内聚(实测 2026-08-22) | 若再增长:确认流拆至 ProfileConfirmFlow.tsx |
6868
| app/src/components/NoteEditView.tsx | 315 | 笔记编辑视图:编辑态/标签/结构面板内聚(前端审查登记) | 若再增长:结构面板拆至 NoteStructurePane.tsx |
6969
| app/src/components/NoteGroupPanel.tsx | 545 | v0.11.0~4 笔记组侧栏:组列表/路由可见可改/碎片捕获/闪卡生成与复习入口/结算仪式/周契约卡/feed 碎片列表(周契约与碎片列表已拆独立组件 WeekContractCard/FeedFragmentList)——组域交互单一面板内聚;v0.11.5 树形合并(buildTree 消费/搜索新建工具条/受控展开组) | 若再增长:结算仪式区拆至 GroupSettlementPane.tsx |
70-
| app/src/components/AiProviderSettings.tsx | 316 | v0.11.6 M1 code-review 修复(2026-08-22):删除/清钥加 window.confirm、window.prompt 改卡片内联 password 输入(+2 state + 内联表单)、模型列表 input 改 textarea、fallbackOrder 透传 initial、run 置"处理中"反馈——修复净增约 41 行越线(实测 316,含 4 行豁免头注释) | 若再增长:内联密钥表单拆至 AiProviderKeyInput.tsx |
70+
| app/src/components/AiProviderSettings.tsx | 329 | v0.11.6 M1 code-review 修复(2026-08-22):删除/清钥加 window.confirm、window.prompt 改卡片内联 password 输入(+2 state + 内联表单)、模型列表 input 改 textarea、fallbackOrder 透传 initial、run 置"处理中"反馈、预设双源 presetOptions 后端拉取——修复净增约 13 行越线(实测 329,含 4 行豁免头注释) | 若再增长:内联密钥表单拆至 AiProviderKeyInput.tsx |
7171

7272
> 前端 **拆分中**(Task #9 笔记域修复进行中,暂不登记行数):`app/src/types.ts`、`app/src/pages/NotesPage.tsx`——待前端拆分完成后以实测行数重新评估。
7373
> 前端 SessionsPage.tsx 审查快照 304 行(登记值),v0.7.1 硬拆后长期 ≤300,本轮审查期间轻微越线;随 NotesPage/types.ts 拆分任务一并复核,若仍越线按上表模式登记。

0 commit comments

Comments
 (0)