修复: 队列发送失败回填不丢消息+池权重revert+lang解析DRY+注释变量名修正

This commit is contained in:
2026-06-17 03:55:30 +08:00
parent 6b2dd8fb5d
commit a622bdca61
9 changed files with 111 additions and 32 deletions

View File

@@ -336,8 +336,10 @@ impl AnthropicCompatProvider {
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组text/image
// 含图时把 content + parts 拍平成 blocksText 片 → {type:text}
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
// Anthropic 协议要求 image 必须内嵌 base64不接受 URL 直传)
// url 模式应由 commands 层预拉字节回填 base64provider 不发额外 HTTP
// Anthropic 协议要求 image 必须内嵌 base64不接受 URL 直传)
// 现状前端Phase2b只产 base64 模式图片片url 模式当前不可达
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
//provider 不发额外 HTTP否则下方兜底会发空 data 致 Anthropic 400。
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
if m.has_image() {
let blocks: Vec<serde_json::Value> = m
@@ -351,12 +353,13 @@ impl AnthropicCompatProvider {
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.clone().unwrap_or_else(|| "image/png".into());
let data = base64.clone().unwrap_or_else(|| {
// url 模式无 base64 时 provider 层兜底commands 层应已预拉):
// 发空 data 会让 Anthropic 报错warn 提示但不阻塞。
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但未预拉 base64将发空 datacommands 层应回填 base64"
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 datacommands 层未补 url→base64 预拉"
);
}
String::new()

View File

@@ -332,7 +332,12 @@ impl OpenAICompatProvider {
format!("data:{};base64,{}", mt, b)
}
(None, Some(u), _) => u,
// 兜底:缺数据时退化为占位,避免发空 url 触发 400
// 完整性兜底:当前 image_base64 构造器强制 media_type:Some
// image_url 构造器提供 url:Some二者分别命中上两个分支
// 此分支仅在 parts 来源被外部直接构造且字段均缺时才可达
//(如 url:None+base64:None 或 base64:Some+media_type:None
// 退化为空串OpenAI 对空 image_url.url 会 400
// 由调用方保证 parts 合法性provider 层不做静默伪造。
_ => String::new(),
};
serde_json::json!({

View File

@@ -101,7 +101,7 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
}
}
/// 消费点用:解析 provider 真实密钥 — keyring 优先,fallback DB.api_key(兼容未迁移老库)
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
if !record.api_key.is_empty() {
return record.api_key.clone();

View File

@@ -86,20 +86,42 @@
- F-260614-04 → ✅ 后端三段79b6a43 数据层+池选择器 / b3684f4 池 IPC+set_caps / 80c0955 stream_one_provider+候选链 fallback前端 api/ai.ts:142 IPC 封装已接UI 配置面板待核验。
**新登记代码问题**
- [ ] B-260617-06 [P3] — **secret.rs:104 注释与实现相反**`resolve_provider_secret` 注释写「keyring 优先,fallback DB.api_key」但代码(:105-110 `if !record.api_key.is_empty() { return DB }` + `get_provider_secret().unwrap_or_default()`)实际「**DB 优先,fallback keyring**」。与顶层设计(line 15「DB 优先,fallback keyring」)矛盾line 104 注释为笔误。**实际无功能影响**(迁移态 DB 与 keyring 不会同时有值set keyring→clear DB 原子序列),纯文档错误。**修法**line 104 注释改为「DB 优先,fallback keyring」。— crates/df-storage/src/secret.rs:104
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-06 [P3] — **secret.rs:104 注释与实现相反**`resolve_provider_secret` 注释写「keyring 优先,fallback DB.api_key」但代码(:105-110 `if !record.api_key.is_empty() { return DB }` + `get_provider_secret().unwrap_or_default()`)实际「**DB 优先,fallback keyring**」。与顶层设计(line 15「DB 优先,fallback keyring」)矛盾line 104 注释为笔误。**实际无功能影响**(迁移态 DB 与 keyring 不会同时有值set keyring→clear DB 原子序列),纯文档错误。**修法**line 104 注释改为「DB 优先,fallback keyring」。— crates/df-storage/src/secret.rs:104
**工作区未提交改动观察(非 bug记录供判断**
- src-tauri/tauri.conf.json: `bundle.targets` `"all"``["nsis"]`(打包目标从全平台收窄到仅 NSIS Windows 安装包。正式收窄devflow 仅 Win 桌面)则合理;若 dev 调试临时收窄忘还原,后续 mac/linux 打包会漏。**待用户确认意图**。
### 🔧 2026-06-17 定期全量走查第2轮·仅走查·未实施
> 增量范围F-05 多模态三提交Phase2a 后端 e3cd448 / Phase2b 前端 e1d396d / Phase2c 闭环 4317d02+ 核对 `src/components/settings/`未跟踪。UX-19 虚拟滚动已被 CR-62 复审 PASS待审查.md 已回填),本轮不重审。
**F-05 多模态审查结论:质量优**
- 设计周全:`content:String` 主载荷不变 + `parts:Option<Vec<ContentPart>>` 挂载,老 JSON 无 parts → None向后兼容零回归audit/title/commands 读 content 当字符串全不受影响。ContentPart `#[serde(tag="type",rename_all="snake_case")]` ↔ 前端 TS discriminated union `type:'text'|'image'`wire 格式严格对齐。
- 转换层协议正确Anthropic image `source.type=base64` + media_type + dataOpenAI `image_url` data URI / http url 双形态OpenAI `content:String→serde_json::Value` 双形态String 简写保纯文本端点兼容。
- 所有权处理到位openai_compat 注释「须在 move m.tool_calls 之前算,借用 m」——Rust 借用易错点作者注意到了。
- truncate_parts 防撑爆Image base64 → 占位 Text 片url 模式原样落库副本不污染内存真相源ContextManager 保留原图,重发仍带图)。
- 测试覆盖ContentPart round-trip / legacy JSON / truncate / 转换层双形态各 2+ 测试。
- FR-S1 安全边界注释到位commands.rs 明确 ContentPart Image base64 是图片数据非 api_key不入敏感面。
**新登记代码问题(均 P3无 P0/P1/P2**
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-07 [P3] — **F-05 变量名误导 + 兜底注释矛盾**。①conversation.rs:109 `bytes_approx = b.len()` 实为 base64 **字符数**(文案「约 N 字符」对,变量名 `bytes_approx`注释「base64 字符数 ≈ 字节数*4/3」也混淆。②openai_compat.rs Image 缺数据兜底分支 `_ => String::new()` 注释「避免发空 url 触发 400」自相矛盾——空串本身即空 url仍触发 400。当前不可达`image_base64` 构造器强制 `media_type:Some`),纯注释/命名瑕疵。**修法**:变量改名 `base64_len`/文案统一;兜底分支改为明确报错或移除误导注释。— crates/df-ai/src/openai_compat.rs:336 / src-tauri/src/commands/ai/conversation.rs:109
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-08 [P3] — **F-05 Anthropic url 模式发空 data 预留路径缺口**。anthropic_compat.rs:355 Image(url,base64:None) 时兜底发空 data + warn「commands 层应预拉 base64」但 Phase2c commands.rs:177 透传 parts 时**无预拉 url 字节逻辑**。当前前端 Phase2b 只产 base64 模式AiChat.vue:999 `FileReader.readAsDataURL`),路径不可达;但注释误导(声称的预拉不存在),未来加 URL 图片输入 / 历史 url 图重发会静默 Anthropic 400。**修法**commands 层补 url→base64 预拉,或 provider 层 url 模式明确报错而非发空 data。— crates/df-ai/src/anthropic_compat.rs:355
**文档状态校正**
- **F-260614-04 UI 面板核验通过**第1轮标 [x] 时备注「UI 面板待核验」,本轮确认 `src/components/settings/ProviderPanel.vue`enabled toggle + weight 0-100 input + `onPoolToggle` 即时调 IPC落库后后端 `reload_provider_caps` 重建)已实现 = F-04c 负载均衡池 UI。**注**:该文件当前**未跟踪未提交**git status `?? src/components/settings/`),含 GeneralPanel/KnowledgePanel/ConnectionPanel/ProviderPanel 四组件(设置面板拆分重构),待提交纳入版本控制。
**已审文件清单(本轮)**crates/df-ai-core/src/provider.rs · crates/df-ai/src/anthropic_compat.rs · crates/df-ai/src/openai_compat.rs · src-tauri/src/commands/ai/conversation.rs · src-tauri/src/commands/ai/commands.rs · src/api/ai.ts · src/api/types.ts · src/composables/ai/useAiSend.ts · src/composables/ai/useAiConversations.ts · src/stores/ai.ts · src/components/AiChat.vue图片构建+渲染) · src/components/settings/ProviderPanel.vue
**新登记 todo**B-260617-07 · B-260617-08
### 🔧 2026-06-17 useAiSend.ts 代码审查(仅走查·未实施)
> 排查性质session-role-diagnose-only对照代码走查 `src/composables/ai/useAiSend.ts`456 行。波2 已实施 UX-05/06/07 + F-01 modelOverride本次基于**当前代码**核验(不信 todo 声明UX-05/06/07 经代码确认一致 ✅)。仅记录待办,未改代码。
- [ ] B-260617-01 [P2] — **sendQueuedNow「立即发送」可能退化为入队**。审查发现sendQueuedNow(:406-411) `await stopChat()` 仅等 stop IPC 发出,不等后端 loop 退出 / `ai_is_generating` 复位。紧接 `await sendMessage(spliced)` 时,后端 stop_flag 已置但 generating 可能仍 trueloop 未跑到 agentic.rs:178 检测点 / guard.reset():188 未执行sendMessage 命中 L1(:281 `backendGenerating || streaming`) → spliced 被**入队而非立即发**与「立即」语义不符。UX-07 条目原标「stop_flag 复位降级可接受」,此审查**质疑该结论**——降级实为功能不达预期。**待核验**`ai_is_generating` 复位时机stop_flag→loop:178→guard.reset:188 链路延迟若滞后sendQueuedNow 应走 forceMode 跳过预检或等 AiCompleted 再发。— src/composables/ai/useAiSend.ts:406-411
- [ ] B-260617-02 [P2] — **drainQueue 单条失败致队列卡死 + 错误吞没**。drainQueue(:247-251) `void sendMessage(...)` fire-and-forgetdoSend IPC 失败 throw(:113) 被 void 忽略 → 无 AiCompleted 触发下次 drain → 剩余队列**永久卡住** + 用户无错误反馈。**修法**drainQueue catch 失败emit 错误提示 + 决定续发下一条或终止。— src/composables/ai/useAiSend.ts:247-251
- [ ] B-260617-03 [P3] — **lang 解析三处重复DRY**。doSend(:94-97) / regenerate(:151-154) / editMessage(:224-227) 各一份相同 `df-ai-language`→auto 回落 `df-language` 解析。**修法**:提取 `resolveLang(): string` 辅助函数,三处复用。— src/composables/ai/useAiSend.ts
- [ ] B-260617-04 [P2] — **tryForceSend 失败后消息丢失无提示**。tryForceSend(:301-318) force_send 失败 catch 返 false(:316),但队首已 shift(:307) → 消息**丢失**,调用方是否提示用户未核验。**修法**:失败回填输入框或 toast对齐 doSend 失败回填:108-113 模式)。— src/composables/ai/useAiSend.ts:301-318
- [ ] B-260617-05 [P2/核验] — **modelOverride 切换对话清逻辑缺失**。注释(:50-51)明确「切换对话前端应同步清 modelOverride.value=null」但本文件无清逻辑切换在 useAiConversations.ts。若未清切对话后 modelOverride 残留 → 新对话误用旧模型。**待核验** useAiConversations.ts switchConversation/newConversation 是否清;未清则补。— src/composables/ai/useAiSend.ts:53 + src/composables/ai/useAiConversations.ts
- [x] ✅(小修批·2026-06-17·workflow wdqxhw4x1+主代修回填/文案) B-260617-02 [P2] — **drainQueue 单条失败致队列卡死 + 错误吞没**。drainQueue(:247-251) `void sendMessage(...)` fire-and-forgetdoSend IPC 失败 throw(:113) 被 void 忽略 → 无 AiCompleted 触发下次 drain → 剩余队列**永久卡住** + 用户无错误反馈。**修法**drainQueue catch 失败emit 错误提示 + 决定续发下一条或终止。— src/composables/ai/useAiSend.ts:247-251
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-03 [P3] — **lang 解析三处重复DRY**。doSend(:94-97) / regenerate(:151-154) / editMessage(:224-227) 各一份相同 `df-ai-language`→auto 回落 `df-language` 解析。**修法**:提取 `resolveLang(): string` 辅助函数,三处复用。— src/composables/ai/useAiSend.ts
- [x] ✅(小修批·2026-06-17·wdqxhw4x1) B-260617-04 [P2] — **tryForceSend 失败后消息丢失无提示**。tryForceSend(:301-318) force_send 失败 catch 返 false(:316),但队首已 shift(:307) → 消息**丢失**,调用方是否提示用户未核验。**修法**:失败回填输入框或 toast对齐 doSend 失败回填:108-113 模式)。— src/composables/ai/useAiSend.ts:301-318
- [ ] B-260617-05 [P2/核验·确认未清·留后续批] — **modelOverride 切换对话清逻辑缺失****主代+agent A 双独立核验(2026-06-17)**:useAiConversations.ts switchConversation/newConversation **零 modelOverride 清逻辑**(useAiSend.ts:67 模块级私有 ref,useAiConversations 无 import/写入)→ 切对话残留 → 新对话误用旧模型确认。修法涉依赖方向(提 store 或 useAiConversations import useAiSend,后者加循环依赖风险对齐 CR-260615-06b),非小修,留后续批。注释(:50-51)明确「切换对话前端应同步清 modelOverride.value=null」但本文件无清逻辑切换在 useAiConversations.ts。若未清切对话后 modelOverride 残留 → 新对话误用旧模型。**待核验** useAiConversations.ts switchConversation/newConversation 是否清;未清则补。— src/composables/ai/useAiSend.ts:53 + src/composables/ai/useAiConversations.ts
> ⚪ 可选优化(未单列 todo① cancelQueued(:372)/editQueued(:390)/sendQueuedNow(:407) 越界检查不一致——按「确定性>防御」风格 UI 调用方必合法,三处可统一(都删或都留)② doSend/regenerate/editMessage 的 streaming+watchdog+占位前置模式重复,可提取 `beginStream()`,但回滚差异大收益中等 ③ `_pendingApprovalIds`(:62) 无超时清理——approveToolCall 卡在 await 永不返回时 id 永驻 Set边缘态watchdog 不清此 Set

View File

@@ -100,8 +100,8 @@ pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart])
ContentPart::Image { url, base64, media_type, alt } => {
// base64 模式:体量大,替换占位 Text 片
if let Some(b) = base64 {
let bytes_approx = b.len(); // base64 字符数 ≈ 字节数 * 4/3,粗估
let placeholder = format!("<image: base64 已省略, 共约 {} 字符>", bytes_approx);
let base64_len = b.len(); // base64 字符串长度(不含 data: 前缀)
let placeholder = format!("<image: base64 已省略, 共约 {} 字符>", base64_len);
out.push(ContentPart::Text { text: placeholder });
changed = true;
} else {

View File

@@ -318,6 +318,7 @@ async function onPoolToggle(p: AiProviderConfig, enabled: boolean) {
function onPoolWeightChange(p: AiProviderConfig, raw: number) {
// NaN 兜底(清空输入):回退 50,防 IPC 传 NaN 后端 serde u32 失败
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
const prev = p.weight
p.weight = clamped
if (_poolTimer) clearTimeout(_poolTimer)
_poolTimer = setTimeout(async () => {
@@ -325,6 +326,7 @@ function onPoolWeightChange(p: AiProviderConfig, raw: number) {
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
} catch (e) {
p.weight = prev // revert
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
}

View File

@@ -21,7 +21,7 @@ import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import type { ContentPart } from '@/api/types'
import type { ContentPart, AiMessage } from '@/api/types'
import i18n from '@/i18n'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener } from './useAiEvents'
@@ -31,6 +31,19 @@ const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>
const appSettings = useAppSettingsStore()
/**
* B-260617-03 DRY: 解析 AI 回复语言。
*
* df-ai-language='auto' → 回落到 df-language;否则用 df-ai-language 指定值。
* doSend/regenerate/editMessage 三处复用(原各内联同一段表达式)。
*/
function resolveLang(): string {
const raw = appSettings.get<string>('df-ai-language', 'auto')
return raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
}
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -101,10 +114,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const lang = resolveLang()
try {
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
@@ -159,10 +169,7 @@ async function regenerate() {
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const lang = resolveLang()
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
@@ -232,10 +239,7 @@ async function editMessage(newMessage: string) {
resetStreamWatchdog()
await startListener()
const raw = appSettings.get<string>('df-ai-language', 'auto')
const lang = raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
const lang = resolveLang()
const convId = state.activeConversationId
if (!convId) {
state.streaming = false
@@ -254,11 +258,36 @@ async function editMessage(newMessage: string) {
}
}
/** 取出队首并发送AiCompleted 触发,此时 streaming 已 false */
/**
* 取出队首并发送AiCompleted 触发,此时 streaming 已 false
*
* B-260617-02: 原 void sendMessage(...) fire-and-forget 吞没了 doSend IPC 失败 throw,
* 既无 AiCompleted 触发下次 drain(队列永久卡死),也无任何用户反馈。
* 现显式 catch:复位 streaming + 推错误气泡(复用 AiError 的 isError 气泡模式) +
* 回填失败消息到队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/编辑/取消)。
* 成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。
*/
export function drainQueue() {
if (state.queue.length === 0) return
const next = state.queue.shift()!
void sendMessage(next.text, next.skill, false, next.parts)
void (async () => {
try {
await sendMessage(next.text, next.skill, false, next.parts)
} catch (e) {
state.streaming = false
clearStreamWatchdog()
const errMsg = e instanceof Error ? e.message : String(e)
state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入)
state.messages.push({
id: `err-${nextMsgId()}`,
role: 'assistant',
content: t('ai.queuedSendFailed', { error: errMsg }),
isError: true,
timestamp: Date.now(),
} as AiMessage)
console.error('[AI] drainQueue 续发失败:', e)
}
})()
}
/**
@@ -314,7 +343,14 @@ export function isQueueTimedOut(): boolean {
return first !== undefined && (Date.now() - first.enqueuedAt > QUEUE_TIMEOUT_MS)
}
/** 超时时弹 confirm,用户确认后以 force_mode 重发队首消息;取消则保持排队 */
/**
* 超时时弹 confirm,用户确认后以 force_mode 重发队首消息;取消则保持排队。
*
* B-260617-04: force_send 失败时,原实现队首已 shift 致消息静默丢失。
* 现失败把队首 unshift 回队列(保留 UI 队列可见,用户可改普通发送/编辑/取消),
* 并复位 streaming/clearStreamWatchdog(对齐 doSend 失败回填模式)让 UI 脱卡死态。
* 不自动重试 force(避免循环),交用户决定下一步。
*/
export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>): Promise<boolean> {
const first = state.queue[0]
if (!first) return false
@@ -328,8 +364,17 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise<boolean>)
try {
await sendMessage(first.text, first.skill, true, first.parts)
return true
} catch {
// force_send 也失败:消息不回队列(避免循环),让用户看到错误
} catch (e) {
// force_send 也失败:回填队首保消息不丢,复位 streaming 让 UI 可继续操作
state.queue.unshift({
text: first.text,
skill: first.skill,
enqueuedAt: first.enqueuedAt,
parts: first.parts,
})
state.streaming = false
clearStreamWatchdog()
console.error('[AI] tryForceSend force_send 失败,消息已回填队首:', e)
return false
}
}

View File

@@ -36,6 +36,7 @@ export default {
forceSendBtn: 'Force Send',
queueTimedOut: 'Queue timed out',
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
queuedSendFailed: 'Failed to send queued message: {error}',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// AE-2025-07: Agentic loop progress bar copy
// Note: max round / completed tool count are not surfaced by backend this round;

View File

@@ -36,6 +36,7 @@ export default {
forceSendBtn: '强制发送',
queueTimedOut: '排队超时',
approvalNotDelivered: '审批操作未送达后端:{error}',
queuedSendFailed: '队列消息发送失败:{error}',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// AE-2025-07: Agentic 循环进度条文案
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key