# F-260614-05 模型能力系统 Phase 2(多模态)实施方案设计 > 状态:📐 设计定稿(2026-06-16,未实施) > 类型:架构设计文档(不碰任何 code) > 关联:F-260614-01(Phase 1 模型能力,未实施)/ F-06 导入历史项目(已留 ImageRef 接口) > 决策记录:见 [功能决策记录-2026-06-14.md](../滚动规范/功能决策记录-2026-06-14.md) §「模型能力系统 Phase 2」行 --- > ## ⚠️ 实施状态(2026-06-18 核对:阶段 1-2 已落地,数据模型形态偏离设计) > > **阶段 1(数据模型)+ 阶段 2(provider 适配)已落地**,阶段 3(前端)+ 阶段 5(F-06 联动)未做。 > > **关键偏离:本文档 §2.1/§2.3 设计「`content: Vec`」,实际落地改为「`content: String` + `parts: Option>`」**(`crates/df-ai-core/src/provider.rs:97/105-106`)。 > - 落地理由(见 `provider.rs:44-51` 注释):未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 `content` 当字符串)零回归,避免一次性改全仓。content 字段始终保留人类可读文本,多模态片挂在 parts。 > - 后果:本文档 §2.2(`deserialize_content` String→单 Text 片)、§2.3(构造器签名 `impl Into` 改 `Vec`)、§2.4(`content_text()` 辅助)、§2.5(`truncate_for_persist` 改 Vec)描述均**不适用**——实际未改 content 类型,truncate 仍作用 String content,老调用点零改动。 > - 实际辅助方法:`provider.rs:157` `user_parts(content, parts)`、`:162` `has_image()`、`:173` `flattened_parts()`(content 前置 Text 片 + parts 追加,供 provider 生成 blocks)。 > > **已落地项(grep 佐证)**: > - `ContentPart` enum:`provider.rs:51-91`(Text/Image 两变体,含 `text()`/`image_base64()`/`image_url()`/`is_image()` 构造与判定)。 > - **token 预算修正**:`crates/df-ai/src/context.rs:49-67` `estimate_message` 已把 parts 的 Image.base64 / Text.text 同 chars_ratio 计入(此前只算 content 致含图消息 token 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500)。注意 `context.rs:57-58` 标注 0.35 比例偏高(CR-260618-11#2),偏保守致含图消息高估、过度裁剪,本次未改值仅标注。 > - **provider 转换**:OpenAI 兼容 `crates/df-ai/src/openai_compat.rs:331-358`(has_image 走 `flattened_parts` → text/image_url 数组,纯文本走字符串简写零回归);Anthropic 兼容 `crates/df-ai/src/anthropic_compat.rs:344-372`(Image → `source.base64 + media_type`,Anthropic 不接受 URL 直传的设计约束落地)。 > > **未做项**: > - 阶段 3 前端(`src/api/types.ts` ContentPart 类型对齐 / AiChat.vue 粘贴拖拽渲染 / store sendMessage payload)—— 未做。 > - 阶段 5 F-06 联动(`commands/project.rs:509-543` extract_description_via_llm 消费 `sample.images` 喂 ContentPart::Image)—— 未做,仍走纯文本 prompt(对齐 §7 注「没图也能跑纯文本降级」)。 > - 阶段 4 vision 路由:F-01 已落地,但 F-01 §6.1 路由已去 cost/intel 硬过滤(B-260618-03),vision 路由仍可按 `TaskRequirements.modalities=[Vision]` + `has_image()` 筛选候选模型,设计方向不变。 --- ## 0. 摘要 将 `ChatMessage.content: String` 升级为 `Vec{Text/Image}`,打通「前端粘贴/拖拽图片 → base64 上行 → OpenAI/Anthropic 兼容端点的 image_url/image blocks」全链路,并在 provider 转换层对非 vision 模型做文本降级。Phase 1(F-01 `ModelCapability`)落地后,由 `ModelRouter` 按 `has_image` 自动路由到带 vision 能力的模型;F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06:`scan.rs::ImageRef` 现仅采集 alt+src,Phase 2 后可由 commands 层读 base64 喂 vision 抽 description。 **与各功能的依赖关系**(详见 §4): - 数据模型 + provider 适配 + 前端渲染 → **不依赖 F-01**,可独立落地。 - vision 自动路由 → **依赖 F-01** 的 `ModelCapability.modalities`,未落地前用「provider 配置的 vision 模型名静态探测」过渡。 - F-06 联动 → 依赖本任务的 ContentPart + provider 适配,**不依赖 F-01**。 --- ## 1. 现状盘点(先 Read 核实) ### 1.1 df-ai-core:ChatMessage 当前形态 `crates/df-ai-core/src/provider.rs:42-83` ```rust pub struct ChatMessage { pub role: MessageRole, pub content: String, // ← 单字符串,升级目标 pub tool_call_id: Option, pub tool_calls: Option>, pub model: Option, pub status: Option, // truncated 软删 } ``` 构造器 `system/user/assistant/tool_result` 全部 `content: impl Into`,散布于 `provider.rs:63-77`。 ### 1.2 provider content 转换现状 **OpenAI 兼容**(`crates/df-ai/src/openai_compat.rs:46-55`): ```rust struct OpenAiMessage { role: String, content: String, // ← 直接透传 ChatMessage.content ... } ``` `convert_request`(openai_compat.rs:301-333)逐字 `content: m.content`。OpenAI 多模态协议要求 `content` 为 `[{type:"text",text},{type:"image_url",image_url:{url}}]` 数组——当前是纯字符串,需改数组化。 **Anthropic 兼容**(`crates/df-ai/src/anthropic_compat.rs:281-382`): - User 消息:`json!({"role":"user","content": m.content})` —— 纯字符串 content(Anthropic 允许字符串简写,但多模态必须数组 blocks)。 - Assistant:已构造 `content: Vec`(anthropic_compat.rs:337-356),是数组形态。 - Tool:`content: m.content`(纯字符串,tool_result 块)。 Anthropic 多模态协议要求 user 消息含 `{"type":"image","source":{"type":"base64","media_type","data"}}` 块。 ### 1.3 持久化路径(向后兼容关键) `src-tauri/src/commands/ai/conversation.rs:79-140`: ```rust let msgs = session.messages.all_messages_clone(); for m in &mut msgs { m.content = truncate_for_persist(&m.content); // ← 截断作用于 String content } let messages_json = serde_json::to_string(&msgs)...; // 整 Vec 序列化成字符串 rec.messages = messages_json; // 落 ai_conversations.messages TEXT 列 ``` `df-storage/src/models.rs:158`:`messages: String`(JSON array of ChatMessage),`truncate_for_persist` 阈值 50KB(conversation.rs:57)。 **核心约束**:升级 `content` 类型后,反序列化老对话的 `{"content":"老文本"}` 必须能读出 `Vec[Text]`,否则历史对话全部炸库。 ### 1.4 前端 ChatMessage 形态 `src/api/types.ts:222-231`: ```ts export interface AiMessage { id: string role: 'user' | 'assistant' | 'tool' content: string // ← 渲染层依赖 isError?: boolean toolCalls?: AiToolCallInfo[] model?: string timestamp: number } ``` `src/components/AiChat.vue:320`:用户消息渲染 `{{ msg.content }}`(纯文本插值);assistant 走 markdown 渲染(流式拼接)。前端 `src/stores/ai.ts:46` `messages: [] as AiMessage[]`。 ### 1.5 F-06 ImageRef 现状 `crates/df-project/src/scan.rs:329-333`: ```rust pub struct ImageRef { pub alt: String, pub src: String, } ``` 注释(scan.rs:326-328)明确:「采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级」。 `collect_sample`(scan.rs:357-380)已把 `images: Vec` 挂进 `ProjectSample`;`src-tauri/src/commands/project.rs:509-543` 的 `extract_description_via_llm` 当前 `build_scan_prompt(&sample, &rule_stack)` 构造纯文本 prompt,**未消费 sample.images**。 ### 1.6 F-01(Phase 1)现状 `crates/df-ai/src/model_probe.rs` / `router.rs` **均不存在**(尚未实施)。但 **F-01 设计已定稿**(2026-06-16,见 [F-01-模型能力系统与智能路由设计-2026-06-16.md](./F-01-模型能力系统与智能路由设计-2026-06-16.md)),定义了 `ModelConfig.modalities` + `ModelRouter` 按 `has_image` 路由。F-05 阶段 1-3 可独立先做,阶段 4 vision 路由待 F-01 实施后接入;过渡期用「provider 配置的 vision 模型名静态白名单」。 --- ## 2. 数据模型设计 ### 2.1 ContentPart 定义(df-ai-core) 在 `crates/df-ai-core/src/provider.rs` 新增: ```rust /// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,二选一非都填)。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentPart { /// 文本片 Text { text: String }, /// 图片片。 /// - url:http(s) 可达 URL(provider 直接转发,不读字节)。 /// - base64:data URI 之外的纯 base64 字符串 + media_type(provider 内嵌转发)。 /// 二选一:base64 非空时 url 忽略,便于前端上行无网络回拉的本地粘贴图。 Image { url: Option, base64: Option, /// base64 模式必填(image/png | image/jpeg | image/webp | image/gif)。 /// url 模式可空(provider 从 URL 后缀嗅探)。 media_type: Option, /// 可选 alt(F-06 README 图引用回填,vision 模型/降级文本时用) #[serde(skip_serializing_if = "Option::is_none")] alt: Option, }, } ``` `ChatMessage.content` 改: ```rust pub struct ChatMessage { pub role: MessageRole, pub content: Vec, ... } ``` ### 2.2 向后兼容:untagged 联合反序列化 老 JSON `{"content":"老文本"}` 必须读成 `content: vec![ContentPart::Text{text:"老文本"}]`。两个方案: **方案 A(推荐):自定义 deserialize,String → 单 Text 片** ```rust fn deserialize_content<'de, D>(d: D) -> Result, D::Error> where D: serde::Deserializer<'de> { use serde::de::Error; let v = serde_json::Value::deserialize(d)?; match v { serde_json::Value::String(s) => Ok(vec![ContentPart::Text { text: s }]), serde_json::Value::Array(_) => { serde_json::from_value::>(v).map_err(D::Error::custom) } other => Err(D::Error::custom(format!("content 期望 string 或 array, 得 {}", other))), } } ``` 字段加 `#[serde(deserialize_with = "deserialize_content", default)]`。 **方案 B(否决):untagged enum(`Content(String) | Parts(Vec))`** —— untagged 反序列化歧义大、报错不直观、调试成本高,pass。 **正向序列化**:恒输出数组形态(`content:[{type:"text",text:"..."}]`),新写入的 JSON 永远是数组;老 String 形态仅在反序列化输入兼容。这意味着 **升级后落库的新对话 JSON 结构变了**,但反序列化双向兼容,无需迁移脚本。 ### 2.3 构造器升级 `provider.rs:63-77` 五个构造器签名改 `impl Into>` 不现实(散落调用点太多)。改用两层: ```rust impl ChatMessage { // 老调用点零改动:String → 单 Text 片 pub fn system(content: impl Into) -> Self { Self { role: System, content: vec![ContentPart::Text{ text: content.into() }], ... } } pub fn user(...) / assistant(...) / assistant_with_tools(...) / tool_result(...) // 同理 // 多模态专用构造器 pub fn user_parts(parts: Vec) -> Self { ... } } ``` 调用点:老 `ChatMessage::user("文本")` 不动;新粘贴图片处用 `user_parts(vec![Text{...}, Image{...}])`。 ### 2.4 辅助方法 ```rust impl ChatMessage { /// 纯文本拼接(所有 Text 片 join),供 truncate_for_persist、prompt 拼接、日志预览。 pub fn content_text(&self) -> String { self.content.iter().filter_map(|p| match p { ContentPart::Text { text } => Some(text.as_str()), _ => None, }).collect::>().join("") } /// 是否含图片片(供 ModelRouter has_image 判定)。 pub fn has_image(&self) -> bool { self.content.iter().any(|p| matches!(p, ContentPart::Image { .. })) } } ``` ### 2.5 truncate_for_persist 影响 `conversation.rs:97` 当前 `m.content = truncate_for_persist(&m.content)`,改后 `m.content` 是 `Vec`: - **Text 片**:对每个 Text 片单独跑 `truncate_for_persist`(保留原头尾截断语义)。 - **Image 片**:base64 字符串通常已 50KB+,**落库前替换为占位 Text 片** `""`,避免大体量图把对话 JSON 撑爆(一张 1MB PNG 的 base64 ≈ 1.3MB 字符串)。 - 重生成/重发时,内存真相源 `ContextManager` 仍持原始 Image 片(截断只作用于持久化副本,对齐现有「持久化视图不污染内存真相源」约定)。 --- ## 3. provider 适配 ### 3.1 OpenAI 兼容(openai_compat.rs) `OpenAiMessage.content` 类型 `String → serde_json::Value`(或 `Vec`): ```rust #[derive(Serialize)] #[serde(untagged)] enum OpenAiContent { Text(String), // 纯文本简写(无图时保持现状兼容老端点) Parts(Vec), } #[derive(Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum OpenAiContentPart { Text { text: String }, ImageUrl { image_url: OpenAiImageUrl }, } #[derive(Serialize)] struct OpenAiImageUrl { url: String } // data:image/png;base64,xxx 或 http(s) URL ``` `convert_request`(openai_compat.rs:301-333)转换: ```rust let content = if m.has_image() { OpenAiContent::Parts(m.content.iter().map(|p| match p { ContentPart::Text { text } => OpenAiContentPart::Text { text: text.clone() }, ContentPart::Image { url, base64, media_type, .. } => { let final_url = match (base64, url, media_type) { (Some(b), _, Some(mt)) => format!("data:{};base64,{}", mt, b), (None, Some(u), _) => u.clone(), _ => String::new(), // 兜底,下方非 vision 降级会丢弃 }; OpenAiContentPart::ImageUrl { image_url: OpenAiImageUrl { url: final_url } } } }).collect()) } else { OpenAiContent::Text(m.content_text()) // 无图走简写字符串,行为同今天 }; ``` **关键取舍**:无图时仍输出字符串 content,**不强行数组化**——避免对纯文本端点(部分自建网关)的兼容性回归。 ### 3.2 Anthropic 兼容(anthropic_compat.rs) User 消息(anthropic_compat.rs:331-334)当前 `json!({"role":"user","content": m.content})`,改: ```rust MessageRole::User => { Self::flush_tool_results(...); let blocks: Vec = m.content.iter().map(|p| match p { ContentPart::Text { text } => json!({"type":"text","text": text}), ContentPart::Image { url, base64, media_type, alt } => { // Anthropic image block 必须是 source.base64 + media_type // Anthropic 不支持 URL 直传(必须 base64)——url 模式由 commands 层预拉字节转 base64 let mt = media_type.clone().unwrap_or_else(|| "image/png".into()); let data = base64.clone().unwrap_or_default(); json!({"type":"image","source":{"type":"base64","media_type": mt,"data": data}}) } }).collect(); messages.push(json!({"role":"user","content": blocks})); } ``` **Anthropic 协议差异(关键约束)**:Anthropic Messages API **不接受 URL**,只接受内嵌 base64。因此: - `Image.base64` 模式:直接转发。 - `Image.url` 模式:Anthropic provider 必须先 HTTP 拉字节 → base64 → 塞进 source。这步放在 provider 适配层(`convert_request` 内异步拉取)或 commands 层预拉。 - **推荐**:commands 层预拉(一处实现,OpenAI/Anthropic 都受益),provider 适配层只做协议格式化,不发起额外 HTTP。`build_completion_request` 之前扫一遍 messages,遇 `Image{url:Some, base64:None}` 就 fetch + base64 编码回填。 ### 3.3 非 vision 模型降级 provider 配置(`AiProviderRecord.default_model` / `models`)当前无能力元数据(F-01 未做)。降级策略: **过渡方案(F-01 未落地)**:在 `AiProviderRecord.config` JSON 里加可选字段 `vision_models: Vec`(用户在 Settings 勾选哪些模型支持图片)。`convert_request` 前查当前 model 是否在 vision 列表: - 在 → 正常发 image parts。 - 不在 → **剥掉所有 Image 片**,只发 Text 片(ContentPart::Image 替换为 ContentPart::Text{text: alt 或 "[图片已省略]"}`),并在 system 消息追加一句「用户消息含图片但当前模型不支持 vision,已转文本描述」。避免发给不支持 vision 的模型导致 400/`invalid content`。 **正式方案(F-01 落地后)**:`ModelCapability.modalities: Vec` 含 `Vision` 时路由器才把带图消息路由到该模型;纯文本模型根本收不到带图消息,降级路径仅作 fallback 兜底(用户手动 override 到非 vision 模型时)。 ### 3.4 stream/响应路径不变 OpenAI/Anthropic 流式响应只回文本 delta(vision 模型生成文本,不回图),`StreamChunk.delta: String` 保持不变。同步 `CompletionResponse.text: String` 也不变。**响应侧零改动**。 --- ## 4. 依赖梳理:F-05 与 F-01 | 子能力 | 是否阻塞 F-01 | 说明 | |---|---|---| | ContentPart 数据模型 | ❌ 不阻塞 | 纯类型升级,与 ModelCapability 解耦 | | provider content 数组化 | ❌ 不阻塞 | OpenAI/Anthropic 协议层改造,与路由器无关 | | 前端粘贴/拖拽/渲染 | ❌ 不阻塞 | 纯前端,依赖后端 ContentPart 类型对齐 | | F-06 ImageRef → vision | ❌ 不阻塞 | commands 层读 base64 喂 ContentPart,不走路由器 | | **vision 模型自动路由** | ✅ **依赖 F-01** | ModelRouter 按 `TaskRequirements.modalities=[Vision]` + `has_image()` 筛候选模型;F-01 未落地时用 §3.3 的 `vision_models` 静态白名单过渡 | **结论**:F-05 **可独立先做 80%**(数据模型 + provider + 前端 + F-06),vision 路由的「按能力自动选模型」留到 F-01 落地后接入;过渡期靠静态白名单+手动 override 让多模态可用。两者实施顺序无强约束,但**建议 F-01 先行或并行**——否则用户要手维护 vision 模型清单,体验割裂。 --- ## 5. 前端改造(本任务仅调研,不改 code) ### 5.1 类型对齐(src/api/types.ts:222) ```ts export type ContentPart = | { type: 'text'; text: string } | { type: 'image'; url?: string; base64?: string; media_type?: string; alt?: string } export interface AiMessage { id: string role: 'user' | 'assistant' | 'tool' content: ContentPart[] // ← 由 string 升级 isError?: boolean toolCalls?: AiToolCallInfo[] model?: string timestamp: number } ``` 调用点(AiChat.vue / store)凡是 `msg.content` 当字符串用的地方: - `AiChat.vue:320` 用户消息插值 `{{ msg.content }}` → 改为遍历 parts,Text 片插值 + Image 片 `` 渲染(base64 拼 `data:` URI 或 url 直 src)。 - assistant markdown 渲染:assistant 消息恒单 Text 片,取 `parts[0].text` 走原 markdown 路径,零回归。 - 导出(markdown/json/txt):遍历 parts,Text 片 join,Image 片输出 `![alt](url)` 或省略标记。 ### 5.2 粘贴/拖拽图片(AiChat.vue 输入区) - `paste` 事件:读 `clipboardData.items`,遇 `image/*` 走 `FileReader.readAsDataURL` → base64 → push 到「待发送图片」暂存区(缩略图预览)。 - `dragover`/`drop`:同上读 `DataTransfer.files`。 - 大小上限校验(见 §6.1):超限直接拒并 toast。 - 发送时构造 `AiMessage{content:[Text片, Image片...]}` 走现有 IPC。 ### 5.3 IPC 形态 `AiChat.vue → store.sendMessage → IPC ai_chat/sendMessage` 当前 payload 含字符串 content。升级为传 ContentPart 数组。后端 `commands/ai/agentic.rs` 接收处同步改类型(依赖 df-ai-core ContentPart 跨 IPC 传递——Tauri 自动 serde)。 ### 5.4 渲染调研结论(不改 code) 当前渲染层对 content 的假设是「字符串可直接插值/markdown」。升级为 parts 后,影响面: - 用户消息:1 处插值(AiChat.vue:320)。 - assistant 消息:markdown 渲染依赖单字符串,需做 `parts → text` 还原(assistant 不会产图)。 - 导出:3 个分支(md/json/txt)。 - 消息编辑/重生成(UX-09):编辑器当前改字符串 content,需改为只编辑 Text 片、保留 Image 片。 工作量中等,无架构阻塞,本任务不实施。 --- ## 6. 边界与约束 ### 6.1 图片大小上限 - **单图 base64 上限**:5MB(编码后 ≈ 6.7MB 字符串)。超限前端拒收,toast 提示。理由:OpenAI 单次请求 body 上限 ~16MB,Anthropic 单 image 推荐 < 5MB;多图叠加易超限。 - **多图上限**:单条消息 ≤ 4 张(对齐主流 vision 模型单轮建议 + token 成本)。 - **格式白名单**:png / jpeg / webp / gif(gif 取首帧,对齐 OpenAI)。bmp/tiff 拒。 ### 6.2 base64 vs 文件引用 - **前端粘贴/拖拽**:必然 base64(浏览器拿不到稳定文件路径,且 Tauri webview 沙箱)。 - **F-06 项目采样图**:`ImageRef.src` 可能是 `./docs/arch.png`(本地相对路径)或 `https://...`(外链)。 - 本地路径:commands 层 `Path::join(root, src)` 读字节 → base64。 - http(s):commands 层 reqwest 拉 → base64(与 Anthropic 协议要求一致,统一预拉)。 - **不存中间文件**:base64 直接进 ContentPart,不落临时文件(避免清理负担)。 ### 6.3 token 成本 - vision 图片按分辨率计费:OpenAI `gpt-4o` 单图 ~85 tokens(detail:low)~ 765 tokens(detail:high)。 - **默认低分辨率**:OpenAI ImageUrl 加 `"detail":"low"`,节省 token。F-06 README 架构图多为概览,low 足够;用户主动粘贴的截图才需 high(前端给个开关,默认 low)。 - Anthropic 无 detail 参数,按图片像素自动计费——采样的图采样层做下采样(commands 层读字节后用 `image` crate resize 到 ≤ 1568px 长边,对齐 Anthropic 推荐)。 ### 6.4 非 vision 模型降级(重申 §3.3) 降级时剥 Image 片,发 Text 片。降级发生场景: 1. 用户手动 override 到非 vision 模型(如纯文本 deepseek-chat)。 2. F-01 未落地、静态白名单未配,默认走 default_model 但 default_model 不支持 vision。 3. provider 网关回 400 `image not supported` → 重试一次降级路径(与现有 retry_with_backoff 互补:retry 处理网络/限流,降级处理能力不匹配)。 ### 6.5 持久化与重载 - 落库 Image base64 占位替换(§2.5):重载历史对话时,前端看到的是「[image: base64 省略]」文本,**不重新展示原图**。理由:① base64 落库撑爆 DB;② 历史对话重看图价值低;③ 真要看图,新发一轮即可。 - **替代方案(更友好,推迟)**:base64 落独立 blob 表 `ai_message_images(message_id, seq, media_type, data)`,重载时 join 还原。**本任务不做**(增量价值低于复杂度,等用户反馈再补)。 ### 6.6 安全 - base64 入 IPC 走现有 mask 机制(FR-S1 api_key mask 同款通道,不暴露明文敏感字段——图片非敏感,但大 payload 影响 IPC 序列化性能,需确认 Tauri 对 >1MB payload 无截断)。 - http(s) 图片预拉:限制只拉 `http(s)` scheme,禁 `file://`(防读任意本地文件)/`ftp://` 等;超时 10s,body 上限 10MB。 --- ## 7. 实施分阶段与文件改动点 > 本任务仅设计,下列为后续实施清单。 ### 阶段 1:数据模型(df-ai-core,不依赖 F-01) | 文件 | 改动 | |---|---| | `crates/df-ai-core/src/provider.rs` | 新增 `ContentPart` enum;`ChatMessage.content: Vec`;自定义 `deserialize_content` 向后兼容;5 个构造器改为 `vec![Text]`;新增 `user_parts`/`content_text`/`has_image` 辅助 | | `crates/df-ai-core/src/lib.rs` | re-export ContentPart | | 调用点核对 | 全仓 `ChatMessage::user/system/assistant/tool_result` 调用点(agentic.rs / title.rs / knowledge*.rs / conversation.rs build_scan_prompt 等)逐个过——构造器签名不变,零改动;但读 `m.content` 当字符串用的地方(如 build_scan_prompt 拼文本)需改 `m.content_text()` | **单测**:反序列化老 JSON(`content:"x"`)→ `vec![Text{x}]`;序列化新结构 → 数组形态;round-trip。 ### 阶段 2:provider 适配(df-ai,依赖阶段 1) | 文件 | 改动 | |---|---| | `crates/df-ai/src/openai_compat.rs` | `OpenAiMessage.content: OpenAiContent`(untagged Text/Parts);`convert_request` 按 has_image 分支;无图走字符串简写保持兼容;有图走 Parts 含 image_url;默认 `detail:low` | | `crates/df-ai/src/anthropic_compat.rs` | User 消息 content blocks 化;Image 片 → `source.base64`;url 模式由 commands 层预拉填 base64(provider 不发额外 HTTP) | | `src-tauri/src/commands/ai/agentic.rs`(或抽 helper) | 新增 `resolve_image_urls(&mut Vec)`:遇 `Image{url:Some,base64:None}` 拉 HTTP/读本地 → base64 + media_type 回填;限制 scheme/超时/大小 | | `src-tauri/src/commands/ai/conversation.rs` | `truncate_for_persist` 改作用于 `Vec`:Text 片单独截断、Image 片替换占位 Text 片(§2.5) | | `crates/df-storage/src/models.rs` | `AiProviderRecord.config` JSON 约定加可选 `vision_models: Vec`(过渡方案,F-01 落地后废弃) | **单测**:OpenAI convert 含图消息 → image_url 结构正确;Anthropic convert 含图消息 → source.base64 结构正确;无图消息 → 字符串简写不变;降级路径(model 非 vision)→ Image 片被剥。 ### 阶段 3:前端(依赖阶段 1 类型对齐) | 文件 | 改动 | |---|---| | `src/api/types.ts` | `AiMessage.content: ContentPart[]`;新增 ContentPart 联合类型 | | `src/components/AiChat.vue` | 用户消息渲染改遍历 parts(Text 插值 + Image ``);输入区 paste/drop 事件读图 → base64 暂存 → 缩略图预览 → 发送时构造 ContentPart[];大小/格式/数量校验(§6.1) | | `src/stores/ai.ts` | sendMessage payload content 改 ContentPart[];发送前 resolve base64 | | 导出(md/json/txt) | parts → text/markdown 还原 | | UX-09 编辑器 | 编辑只改 Text 片、Image 片保留 | | i18n | 图片上限/格式/数量校验文案 | ### 阶段 4:vision 路由(依赖 F-01) | 文件 | 改动 | |---|---| | `crates/df-ai/src/router.rs`(F-01 新建) | `TaskRequirements` 含 `modalities: Vec`;router 按 has_image 给主对话注入 `Vision` requirement;筛候选模型 `ModelCapability.modalities` 含 Vision | | `src-tauri/src/commands/ai/agentic.rs` | 主对话路由调用点传 `has_image = messages.iter().any(ChatMessage::has_image)` | F-01 未落地前,阶段 1-3 已让多模态可用(静态白名单 + 手动 override),阶段 4 是「自动选模型」锦上添花。 ### 阶段 5:F-06 联动(依赖阶段 1+2) | 文件 | 改动 | |---|---| | `src-tauri/src/commands/project.rs:509-543` `extract_description_via_llm` | 在 `build_scan_prompt` 后,遍历 `sample.images`:读本地字节/拉 URL → base64 → push `ContentPart::Image` 进 user 消息;prompt 文本加「以下是项目架构图/截图,结合 README 文本输出 description」;vision 模型判定走 §3.3 白名单(无 vision 模型则跳过图,纯文本降级,对齐现状) | | `crates/df-project/src/scan.rs` | ImageRef 加可选 `local_abs_path` 字段?**否决**——采样层不知道 root 之外的解析上下文;commands 层用 `Path::join(root, src)` 解析相对路径即可,scan.rs 零改动 | | 图片采样预处理 | commands 层读字节后 `image` crate resize(≤1568px 长边,对齐 Anthropic;OpenAI 也受益降 token) | **注意**:F-06 联动**不阻塞 F-05 主体**——F-06 的 description 抽取即使没图也能跑(纯文本降级,今天就是这样)。阶段 5 是「图也喂进去,vision 模型看图给更好的 description」,增量价值。 --- ## 8. 风险与未决 1. **Tauri IPC 大 payload**:单张 5MB base64 经 IPC 序列化可能 >6MB,需验证 Tauri v2 无截断/超时。降级:前端压缩到 ≤2MB 再上行。 2. **provider 网关 image 兼容性参差**:GLM/DeepSeek 的 OpenAI 兼容端点对 image_url 支持度不一(有的只认 URL 不认 data URI)。落地前需对 GLM-4V / DeepSeek(无 vision)/ Claude 实测,遇不兼容端点在 provider config 标 `vision_endpoint_quirk` 走特殊路径。 3. **历史对话图片不回显**(§6.5 占位策略)——用户体验损失,需 i18n 文案说明,或后续做 blob 表。 4. **Anthropic URL 模式必须预拉 base64**(§3.2)——commands 层预拉增加延迟(单图 <1s 可接受,多图并发拉需限流)。 5. **truncate_for_persist 改 Vec 后单测**:conversation.rs:237-267 现有 3 个 truncate 单测针对 String,需重写为 Vec 版本。 6. **F-01 时序**:若 F-01 长期不落地,阶段 4 缺失,用户需手维护 vision_models 白名单——Settings UI 要给个勾选入口(属 F-01 的 Settings 模型池编辑,本任务不动 Settings)。 --- ## 9. 决策记录锚点 以下决策已在 [功能决策记录-2026-06-14.md](../滚动规范/功能决策记录-2026-06-14.md) 记录或待补: - 「分阶段实施路线」§(行 134-138):Phase 1 不改 content / Phase 2 多模态 / Phase 3 Agent 内路由 —— 本文档展开 Phase 2。 - 新增决策(建议补入功能决策记录,由后续会话触发 decision-record 技能): 1. content 向后兼容用自定义 deserialize(String→单 Text 片),否决 untagged enum。 2. 无图消息 content 恒字符串简写,不强行数组化(避免纯文本端点回归)。 3. Image base64 落库前替换占位 Text 片(不存 blob 表,增量价值低于复杂度)。 4. Anthropic URL 模式 commands 层预拉 base64(provider 不发额外 HTTP)。 5. F-05 与 F-01 解耦:前 3 阶段独立可跑,阶段 4 路由依赖 F-01。 --- ## 10. 不做的事(显式排除) - 不做视频/音频片(ContentPart 只 Text/Image)。 - 不做图片编辑(裁剪/标注)——前端原图上行。 - 不做 base64 blob 独立表(§6.5 推迟)。 - 不做 OCR fallback(vision 模型自带文字识别能力)。 - 不做流式图片输出(vision 模型只回文本 delta)。 - 不改 AiMessage.id/role/timestamp 等非 content 字段。 - 不碰 F-01 的 ModelCapability 数据模型(属 F-01 范畴)。 - 本文档不实施任何 code(纯设计)。