Skip to content

Commit f3df977

Browse files
committed
fix(ai): 审查修复——流槽注册提前/编辑重发会话校验/失败错误持久化/流隔离/轮询优化(v0.16.0)
1 parent 6778114 commit f3df977

11 files changed

Lines changed: 385 additions & 181 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//! AI 对话客户端解析与流槽管理(REQ-224/225,v0.16.0)。
2+
//!
3+
//! @ai-context: 审查拆分(2026-08-30,commands_ai_chat.rs 324 行 > 300 硬上限)
4+
//! ——会话校验 / Provider→AiClient 解析 / 单活跃流槽生命周期
5+
//! 内聚本模块;命令层只做入参校验与编排。
6+
//! @ai-context: 流槽纪律:try_begin_stream 必须早于任何落库(防并发
7+
//! chat_send 先重复落库再被拒);前置校验失败路径须 end_stream
8+
//! 清理(防流槽泄漏——后续发送永被拒)。
9+
10+
use crate::ai_chat::CancelFlag;
11+
use crate::ai_client::AiClient;
12+
use crate::ai_provider::{AiProviderConfig, ProviderKind, provider_scope};
13+
use crate::commands::AppState;
14+
use crate::db_ai_chat::ChatSession;
15+
16+
/// 会话存在性校验(所有 chat_* 命令公共前置——Tauri IPC 入参校验红线)。
17+
pub fn validate_session(state: &AppState, session_id: i64) -> Result<ChatSession, String> {
18+
state
19+
.db
20+
.get_chat_session(session_id)
21+
.map_err(|e| e.to_string())?
22+
.ok_or_else(|| "会话不存在".to_string())
23+
}
24+
25+
/// 会话客户端解析:session.provider_id 显式 > 默认 Provider;密钥口径
26+
/// 显式=per-scope(env 不覆盖显式选择),默认=resolve_default_provider_key
27+
/// (env > per-provider > legacy,与精修链同口径)。
28+
pub fn resolve_chat_client(
29+
state: &AppState,
30+
session: &ChatSession,
31+
) -> Result<(AiClient, Option<String>), String> {
32+
let store = state
33+
.ai_providers
34+
.lock()
35+
.map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?
36+
.clone();
37+
if let Some(pid) = session.provider_id.clone() {
38+
let provider = store
39+
.get(&pid)
40+
.cloned()
41+
.ok_or_else(|| format!("Provider {} 不存在(请到设置页检查)", pid))?;
42+
let client = build_provider_client(state, &provider, &pid)?;
43+
return Ok((client, Some(pid)));
44+
}
45+
let settings = state
46+
.ai_settings
47+
.lock()
48+
.map_err(|e| format!("AI 设置锁中毒: {}", e))?
49+
.clone();
50+
let stored_key = crate::commands_ai_providers::resolve_default_provider_key(state)?;
51+
Ok((AiClient::from_settings_with_store(&settings, stored_key, &store), None))
52+
}
53+
54+
/// 显式 Provider 客户端(Ollama 免密钥;其余 per-scope 凭据缺失 → 明确报错)。
55+
pub fn build_provider_client(
56+
state: &AppState,
57+
provider: &AiProviderConfig,
58+
pid: &str,
59+
) -> Result<AiClient, String> {
60+
if provider.kind == ProviderKind::Ollama {
61+
return Ok(AiClient::from_provider(provider, None));
62+
}
63+
let key = state
64+
.ai_credentials
65+
.load_key(&provider_scope(pid))?
66+
.ok_or_else(|| format!("Provider {} 未保存密钥(设置页保存后重试)", provider.name))?;
67+
Ok(AiClient::from_provider(provider, Some(key)))
68+
}
69+
70+
/// 单活跃流注册(gate 之后、任何落库之前调用——防并发重复落库/扣费;
71+
/// 失败方负责 end_stream 清理)。
72+
pub fn try_begin_stream(state: &AppState, session_id: i64) -> Result<CancelFlag, String> {
73+
let flag = CancelFlag::new();
74+
let mut cancels = state
75+
.chat_cancels
76+
.lock()
77+
.map_err(|e| format!("取消表锁中毒: {}", e))?;
78+
if cancels.insert(session_id, flag.clone()).is_some() {
79+
return Err("该会话已有进行中的对话——请等待完成或先停止".to_string());
80+
}
81+
Ok(flag)
82+
}
83+
84+
/// 释放单活跃流(落库失败/前置校验失败路径——保证流槽不泄漏)。
85+
pub fn end_stream(state: &AppState, session_id: i64) {
86+
state.chat_cancels.lock().unwrap_or_else(|x| x.into_inner()).remove(&session_id);
87+
}
88+
89+
/// 截断到上限字符数(错误持久化文本——防超长错误撑爆消息列表)。
90+
pub fn truncate_chars(s: &str, max: usize) -> String {
91+
if s.chars().count() <= max {
92+
return s.to_string();
93+
}
94+
let cut: String = s.chars().take(max).collect();
95+
format!("{}…", cut)
96+
}

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

Lines changed: 50 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ use tauri::ipc::Channel;
1111
use tauri::State;
1212

1313
use crate::ai_chat::{
14-
AiTurn, CancelFlag, ChatMessageInput, ChatRole, build_messages, trajectory_from_json,
14+
AiTurn, ChatMessageInput, ChatRole, build_messages, trajectory_from_json,
15+
};
16+
use crate::ai_chat_client::{
17+
end_stream, resolve_chat_client, truncate_chars, try_begin_stream, validate_session,
1518
};
1619
use crate::ai_chat_stream::{ChatStreamEvent, stream_chat};
1720
use crate::ai_client::AiClient;
18-
use crate::ai_provider::{AiProviderConfig, ProviderKind, provider_scope};
1921
use crate::commands::AppState;
2022
use crate::db_ai_chat::{ChatMessage, ChatSession};
2123

@@ -26,59 +28,6 @@ const CHAT_SYSTEM_PROMPT: &str =
2628
/// 单条消息最大字符数(防误粘贴巨文——超限明确拒绝而非静默截断)。
2729
const MAX_MESSAGE_CHARS: usize = 16000;
2830

29-
fn validate_session(state: &AppState, session_id: i64) -> Result<ChatSession, String> {
30-
state
31-
.db
32-
.get_chat_session(session_id)
33-
.map_err(|e| e.to_string())?
34-
.ok_or_else(|| "会话不存在".to_string())
35-
}
36-
37-
/// 会话客户端解析:session.provider_id 显式 > 默认 Provider;密钥口径
38-
/// 显式=per-scope(env 不覆盖显式选择),默认=resolve_default_provider_key
39-
/// (env > per-provider > legacy,与精修链同口径)。
40-
fn resolve_chat_client(
41-
state: &AppState,
42-
session: &ChatSession,
43-
) -> Result<(AiClient, Option<String>), String> {
44-
let store = state
45-
.ai_providers
46-
.lock()
47-
.map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?
48-
.clone();
49-
if let Some(pid) = session.provider_id.clone() {
50-
let provider = store
51-
.get(&pid)
52-
.cloned()
53-
.ok_or_else(|| format!("Provider {} 不存在(请到设置页检查)", pid))?;
54-
let client = build_provider_client(state, &provider, &pid)?;
55-
return Ok((client, Some(pid)));
56-
}
57-
let settings = state
58-
.ai_settings
59-
.lock()
60-
.map_err(|e| format!("AI 设置锁中毒: {}", e))?
61-
.clone();
62-
let stored_key = crate::commands_ai_providers::resolve_default_provider_key(state)?;
63-
Ok((AiClient::from_settings_with_store(&settings, stored_key, &store), None))
64-
}
65-
66-
/// 显式 Provider 客户端(Ollama 免密钥;其余 per-scope 凭据缺失 → 明确报错)。
67-
fn build_provider_client(
68-
state: &AppState,
69-
provider: &AiProviderConfig,
70-
pid: &str,
71-
) -> Result<AiClient, String> {
72-
if provider.kind == ProviderKind::Ollama {
73-
return Ok(AiClient::from_provider(provider, None));
74-
}
75-
let key = state
76-
.ai_credentials
77-
.load_key(&provider_scope(pid))?
78-
.ok_or_else(|| format!("Provider {} 未保存密钥(设置页保存后重试)", provider.name))?;
79-
Ok(AiClient::from_provider(provider, Some(key)))
80-
}
81-
8231
/// 新建会话(标题可空——默认"新对话")。
8332
#[tauri::command]
8433
pub fn chat_create_session(state: State<'_, AppState>, title: Option<String>) -> Result<ChatSession, String> {
@@ -171,19 +120,42 @@ pub async fn chat_send(
171120
// 先解析客户端(Provider/密钥缺失 → 明确报错,不落无应答的用户消息)
172121
let (client, provider_id) = resolve_chat_client(&state, &session)?;
173122
let model = client.config.model.clone();
174-
// 编辑后重发:改内容 + 作废旧回答(其后消息全删)
123+
// 单活跃流注册必须早于任何落库(审查修复:原在 run_stream 内注册——
124+
// 并发 chat_send 会先重复落库再被拒,防重复扣费/重复请求)
125+
let flag = try_begin_stream(&state, session_id)?;
126+
// 编辑后重发:改内容 + 作废旧回答(其后消息全删);
127+
// 入参校验(审查修复):消息必须属于本会话且为 user 角色(防跨会话误改)
175128
if let Some(mid) = resend_message_id {
176-
state.db.update_chat_message_content(mid as i64, &content).map_err(|e| e.to_string())?;
177-
state.db.delete_chat_messages_after(session_id, mid as i64).map_err(|e| e.to_string())?;
178-
} else {
179-
state.db.insert_chat_message(session_id, "user", &content, "done").map_err(|e| e.to_string())?;
129+
let role = state.db.chat_message_role(session_id, mid as i64).map_err(|e| e.to_string())?;
130+
match role.as_deref() {
131+
Some("user") => {}
132+
Some(_) => {
133+
end_stream(&state, session_id);
134+
return Err("编辑重发只能作用于用户消息(assistant 消息用「重发」)".to_string());
135+
}
136+
None => {
137+
end_stream(&state, session_id);
138+
return Err("要编辑的消息不存在或不属于该会话".to_string());
139+
}
140+
}
141+
if let Err(e) = state.db.update_chat_message_content(session_id, mid as i64, &content) {
142+
end_stream(&state, session_id);
143+
return Err(e.to_string());
144+
}
145+
if let Err(e) = state.db.delete_chat_messages_after(session_id, mid as i64) {
146+
end_stream(&state, session_id);
147+
return Err(e.to_string());
148+
}
149+
} else if let Err(e) = state.db.insert_chat_message(session_id, "user", &content, "done") {
150+
end_stream(&state, session_id);
151+
return Err(e.to_string());
180152
}
181153
if let Some(pid) = &provider_id {
182154
state.db.set_chat_session_model(session_id, Some(pid), &model).map_err(|e| e.to_string())?;
183155
} else {
184156
state.db.set_chat_session_model(session_id, None, &model).map_err(|e| e.to_string())?;
185157
}
186-
run_stream(&state, session_id, model, client, channel)
158+
run_stream(&state, session_id, model, client, channel, flag)
187159
}
188160

189161
/// 重新生成(重发/重试):删除最后一条 assistant(含 failed/aborted 占位)后重流。
@@ -203,13 +175,19 @@ pub async fn chat_regenerate(
203175
// 先解析客户端(Provider/密钥失败 → 不删旧回答,保留可重发状态)
204176
let (client, _provider_id) = resolve_chat_client(&state, &session)?;
205177
let model = client.config.model.clone();
178+
// 单活跃流注册早于删除动作(审查修复:与 chat_send 同纪律)
179+
let flag = try_begin_stream(&state, session_id)?;
206180
let msgs = state.db.list_chat_messages(session_id).map_err(|e| e.to_string())?;
207181
if let Some(last_assistant) = msgs.iter().rev().find(|m| m.role == "assistant") {
208-
state.db.delete_chat_message(session_id, last_assistant.id).map_err(|e| e.to_string())?;
182+
if let Err(e) = state.db.delete_chat_message(session_id, last_assistant.id) {
183+
end_stream(&state, session_id);
184+
return Err(e.to_string());
185+
}
209186
} else {
187+
end_stream(&state, session_id);
210188
return Err("没有可重新生成的消息".to_string());
211189
}
212-
run_stream(&state, session_id, model, client, channel)
190+
run_stream(&state, session_id, model, client, channel, flag)
213191
}
214192

215193
/// 停止(置取消标志 → 流循环下一行检查短路;无进行中流则 no-op)。
@@ -225,25 +203,15 @@ pub fn chat_cancel(state: State<'_, AppState>, session_id: i64) -> Result<(), St
225203
Ok(())
226204
}
227205

228-
/// 单活跃流编排(chat_send/chat_regenerate 共用)。
206+
/// 单活跃流编排(chat_send/chat_regenerate 共用;flag 由调用方注册)。
229207
fn run_stream(
230208
state: &AppState,
231209
session_id: i64,
232210
model: String,
233211
client: AiClient,
234212
channel: Channel<ChatStreamEvent>,
213+
flag: crate::ai_chat::CancelFlag,
235214
) -> Result<(), String> {
236-
// 单活跃流:同会话已有进行中的发送 → 拒绝(防并发重复扣费)
237-
let flag = CancelFlag::new();
238-
{
239-
let mut cancels = state
240-
.chat_cancels
241-
.lock()
242-
.map_err(|e| format!("取消表锁中毒: {}", e))?;
243-
if cancels.insert(session_id, flag.clone()).is_some() {
244-
return Err("该会话已有进行中的对话——请等待完成或先停止".to_string());
245-
}
246-
}
247215
// 历史组装(最新消息已入库;failed 占位不喂上下文——用户已见错误)。
248216
// 列表失败不阻断流传输(消息刚插入过);打印可观测,历史为空保守降级。
249217
let messages = match state.db.list_chat_messages(session_id) {
@@ -271,11 +239,13 @@ fn run_stream(
271239
}) {
272240
Ok(o) => o,
273241
Err(e) => {
274-
// 失败:落 assistant 占位(status=failed,空内容)——前端错误
275-
// 气泡 + 重试(chat_regenerate 删占位后重流);不静默不丢失
242+
// 失败:占位消息 content=错误原文(审查修复:L2 优先级——错误
243+
// 文本持久化,前端失败气泡不再依赖瞬态事件态,重启/切换后仍可读);
244+
// 重试=chat_regenerate(删占位后重流)
245+
let msg = e.to_string();
276246
let _ = channel.send(ChatStreamEvent::from(&e));
277-
let _ = st.db.insert_chat_message(session_id, "assistant", "", "failed");
278-
st.chat_cancels.lock().unwrap_or_else(|x| x.into_inner()).remove(&session_id);
247+
let _ = st.db.insert_chat_message(session_id, "assistant", &truncate_chars(&msg, 500), "failed");
248+
end_stream(&st, session_id);
279249
return;
280250
}
281251
};
@@ -298,7 +268,7 @@ fn run_stream(
298268
} else {
299269
let _ = channel.send(ChatStreamEvent::Done { content: outcome.content, usage_json: outcome.usage_json });
300270
}
301-
st.chat_cancels.lock().unwrap_or_else(|x| x.into_inner()).remove(&session_id);
271+
end_stream(&st, session_id);
302272
});
303273
Ok(())
304274
}

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,25 @@ impl Db {
194194
Ok(())
195195
}
196196

197-
/// 编辑历史消息内容(编辑后重发;仅 user 消息语义)。
198-
pub fn update_chat_message_content(&self, id: i64, content: &str) -> Result<()> {
197+
/// 消息角色查询(编辑重发入参校验:必须属于该会话且为 user——防跨会话误改)。
198+
pub fn chat_message_role(&self, session_id: i64, message_id: i64) -> Result<Option<String>> {
199+
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
200+
conn.query_row(
201+
"SELECT role FROM chat_messages WHERE id=?1 AND session_id=?2",
202+
params![message_id, session_id],
203+
|row| row.get::<_, String>(0),
204+
)
205+
.optional()
206+
.map_err(Into::into)
207+
}
208+
209+
/// 编辑历史消息内容(编辑后重发;会话限定——审查修复:原无 session 条件,
210+
/// IPC 可传任意消息 id 误改他会话内容)。
211+
pub fn update_chat_message_content(&self, session_id: i64, id: i64, content: &str) -> Result<()> {
199212
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
200213
conn.execute(
201-
"UPDATE chat_messages SET content=?1, created_at=?2 WHERE id=?3",
202-
params![content, crate::db_sessions_rows::unix_seconds(), id],
214+
"UPDATE chat_messages SET content=?1, created_at=?2 WHERE id=?3 AND session_id=?4",
215+
params![content, crate::db_sessions_rows::unix_seconds(), id, session_id],
203216
)?;
204217
Ok(())
205218
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,30 @@ fn edit_resend_deletes_messages_after() {
9090
let sid = db.insert_chat_session(None).unwrap();
9191
let m1 = db.insert_chat_message(sid, "user", "原来", "done").unwrap();
9292
let _m2 = db.insert_chat_message(sid, "assistant", "旧回答", "done").unwrap();
93-
// 编辑后重发:改内容 + 删除其后消息
94-
db.update_chat_message_content(m1, "改后").unwrap();
93+
// 编辑后重发:改内容 + 删除其后消息(会话限定)
94+
db.update_chat_message_content(sid, m1, "改后").unwrap();
9595
db.delete_chat_messages_after(sid, m1).unwrap();
9696
let msgs = db.list_chat_messages(sid).unwrap();
9797
assert_eq!(msgs.len(), 1);
9898
assert_eq!(msgs[0].content, "改后");
9999
// m2 已删(id > m1 排除)
100100
assert!(db.list_chat_messages(sid).unwrap().len() == 1);
101+
// 跨会话编辑被拒(会话限定——查无此行,内容不变)
102+
let s2 = db.insert_chat_session(None).unwrap();
103+
db.update_chat_message_content(s2, m1, "篡改").unwrap();
104+
assert_eq!(db.list_chat_messages(sid).unwrap()[0].content, "改后");
105+
}
106+
107+
#[test]
108+
fn chat_message_role_scoped_and_role_report() {
109+
let db = open_mem();
110+
let sid = db.insert_chat_session(None).unwrap();
111+
let um = db.insert_chat_message(sid, "user", "u", "done").unwrap();
112+
let am = db.insert_chat_message(sid, "assistant", "a", "done").unwrap();
113+
assert_eq!(db.chat_message_role(sid, um).unwrap().as_deref(), Some("user"));
114+
assert_eq!(db.chat_message_role(sid, am).unwrap().as_deref(), Some("assistant"));
115+
// 跨会话 id → None(不入参校验即误改的根因防线)
116+
assert_eq!(db.chat_message_role(sid + 100, um).unwrap(), None);
101117
}
102118

103119
#[test]

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ mod db_ai_usage;
4848
mod db_ai_tasks;
4949
mod commands_notes_version;
5050
// v0.16.0(REQ-224/225/230):AI 对话——纯函数层(消息组装/SSE 解析/
51-
// 轨迹序列化)+ 流式发送 + 会话/消息持久化 + 命令层
51+
// 轨迹序列化)+ 流式发送 + 会话/消息持久化 + 命令层 + 客户端解析(审查拆分)
5252
mod ai_chat;
5353
mod db_ai_chat;
5454
mod ai_chat_stream;
55+
mod ai_chat_client;
5556
mod commands_ai_chat;
5657
mod asr;
5758
mod asr_clean;

‎app/src/components/ChatMessageList.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ import type { ChatMessage } from "../types";
1010
import ChatMessageMarkdown from "./ChatMessageMarkdown";
1111

1212
export interface StreamingState {
13-
/** 流式累积文本(null=非流式) */
13+
/** 流式累积文本(非 null = 流式生成中) */
1414
text: string | null;
15-
error: { kind: string; message: string } | null;
1615
}
1716

1817
interface Props {
@@ -79,7 +78,7 @@ export default function ChatMessageList({ messages, streaming, onRegenerate, onE
7978
</div>
8079
) : m.status === "failed" ? (
8180
<div style={{ border: "1px solid #fecaca", background: "#fef2f2", borderRadius: 8, padding: "8px 10px", fontSize: 12.5, color: "#991b1b" }}>
82-
生成失败:{streaming?.error?.message ?? "未知错误"}
81+
生成失败:{m.content || "未知错误"}
8382
<div>
8483
<button onClick={onRegenerate} style={{ marginTop: 6, fontSize: 12, padding: "2px 10px", border: "1px solid #fca5a5", borderRadius: 6, background: "#fff", color: "#b91c1c", cursor: "pointer" }}>
8584
↩ 重试

0 commit comments

Comments
 (0)