diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index 9bb907f..c55012d 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -5,7 +5,7 @@ use std::sync::atomic::Ordering; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; -use df_ai::provider::ChatMessage; +use df_ai::provider::{ChatMessage, ContentPart}; // df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。 use df_ai::df_ai_core::model::ModelConfig; use df_types::types::new_id; @@ -141,6 +141,12 @@ pub async fn ai_chat_send( language: Option, skill: Option, model_override: Option, + // F-260614-05 Phase 2c: 多模态 parts(前端 invoke 透传,serde 反序列化为 ContentPart 数组)。 + // None/空 → 等价 user()(纯文本,向后兼容);Some(含 Image 片) → user_parts() + provider vision 端点。 + // FR-S1 核验:ContentPart Image base64 是图片数据非 api_key,不入敏感面 + // (不进 NodeContext.config/NodeOutput/tool_call schema/IPC 返回值/日志/错误), + // 仅经 ai_chat_send 落 session.messages + 经 provider 请求(波20 truncate_parts 防 DB 撑爆)。 + parts: Option>, ) -> Result { // 获取活跃提供商(只读,失败可直接返回,不影响生成标志) let provider_config = super::prompt::get_active_provider(&state).await?; @@ -171,7 +177,13 @@ pub async fn ai_chat_send( } else { message.clone() }; - session.messages.push(ChatMessage::user(&user_content)); + // F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。 + // user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。 + if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) { + session.messages.push(ChatMessage::user_parts(&user_content, ps.clone())); + } else { + session.messages.push(ChatMessage::user(&user_content)); + } // 首次发送时生成对话 id(懒创建:不立即落库,避免空对话残留; // 实际记录由 save_conversation 在生成内容后 upsert 写入) @@ -724,6 +736,8 @@ pub async fn ai_chat_force_send( language: Option, skill: Option, model_override: Option, + // F-260614-05 Phase 2c: 透传 parts 到 ai_chat_send(强制发送同款多模态支持)。 + parts: Option>, ) -> Result { // 原子复位:清 generating + 清积压审批 + 置 stop_flag,与 ai_chat_stop 审批分支一致 let old_conv_id = { @@ -746,7 +760,7 @@ pub async fn ai_chat_force_send( } // 复位完成后走 ai_chat_send 同款流程(内部会重新设 generating=true 并 spawn loop) // 直接内联而非递归调 ai_chat_send,避免 IPC 嵌套 - ai_chat_send(app, state, message, language, skill, model_override).await + ai_chat_send(app, state, message, language, skill, model_override, parts).await } /// 停止当前 AI 生成 diff --git a/src/api/ai.ts b/src/api/ai.ts index ce8f4a0..240f68e 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -2,26 +2,32 @@ import { invoke } from '@tauri-apps/api/core' import { listen, type UnlistenFn } from '@tauri-apps/api/event' -import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, ModelConfig, SkillInfo } from './types' +import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, ContentPart, ModelConfig, SkillInfo } from './types' export const aiApi = { - /** 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md */ - sendMessage(message: string, language?: string, skill?: string, modelOverride?: string | null): Promise { + /** + * 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md。 + * F-260614-05 Phase 2c: parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。 + * parts 为空/undefined → 后端走 user() 纯文本(向后兼容)。 + */ + sendMessage(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[]): Promise { return invoke('ai_chat_send', { message, language: language || 'zh-CN', skill: skill || null, modelOverride: modelOverride || null, + parts: parts && parts.length > 0 ? parts : null, }) }, - /** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程) */ - forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null): Promise { + /** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程);parts 透传同 sendMessage。 */ + forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[]): Promise { return invoke('ai_chat_force_send', { message, language: language || 'zh-CN', skill: skill || null, modelOverride: modelOverride || null, + parts: parts && parts.length > 0 ? parts : null, }) }, diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index edc3514..7e4c60c 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -110,9 +110,10 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte // F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用) const override = modelOverride.value if (force) { - await aiApi.forceSend(text.trim(), lang, skill, override) + // F-05 Phase 2c: 透传 parts(图片)给后端 ai_chat_force_send,多模态全链生效 + await aiApi.forceSend(text.trim(), lang, skill, override, parts) } else { - await aiApi.sendMessage(text.trim(), lang, skill, override) + await aiApi.sendMessage(text.trim(), lang, skill, override, parts) } } catch (e) { // IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;