Files
DevFlow/docs/02-架构设计/已编号方案/F-05-多模态实施方案设计-2026-06-16.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

30 KiB
Raw Blame History

F-260614-05 模型能力系统 Phase 2多模态实施方案设计

状态:📐 设计定稿2026-06-16未实施 类型:架构设计文档(不碰任何 code 关联F-260614-01Phase 1 模型能力,未实施)/ F-06 导入历史项目(已留 ImageRef 接口) 决策记录:见 功能决策记录-2026-06-14.md §「模型能力系统 Phase 2」行


⚠️ 实施状态2026-06-18 核对:阶段 1-2 已落地,数据模型形态偏离设计)

阶段 1数据模型+ 阶段 2provider 适配)已落地,阶段 3前端+ 阶段 5F-06 联动)未做。

关键偏离:本文档 §2.1/§2.3 设计「content: Vec<ContentPart>」,实际落地改为「content: String + parts: Option<Vec<ContentPart>>crates/df-ai-core/src/provider.rs:97/105-106)。

  • 落地理由(见 provider.rs:44-51 注释未接入多模态的调用方audit/title/commands/knowledge_inject 等读 content 当字符串零回归避免一次性改全仓。content 字段始终保留人类可读文本,多模态片挂在 parts。
  • 后果:本文档 §2.2deserialize_content String→单 Text 片、§2.3(构造器签名 impl Into<String>Vec、§2.4content_text() 辅助、§2.5truncate_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 enumprovider.rs:51-91Text/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-358has_image 走 flattened_parts → text/image_url 数组纯文本走字符串简写零回归Anthropic 兼容 crates/df-ai/src/anthropic_compat.rs:344-372Image → source.base64 + media_typeAnthropic 不接受 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-03vision 路由仍可按 TaskRequirements.modalities=[Vision] + has_image() 筛选候选模型,设计方向不变。

0. 摘要

ChatMessage.content: String 升级为 Vec<ContentPart>{Text/Image},打通「前端粘贴/拖拽图片 → base64 上行 → OpenAI/Anthropic 兼容端点的 image_url/image blocks」全链路并在 provider 转换层对非 vision 模型做文本降级。Phase 1F-01 ModelCapability)落地后,由 ModelRouterhas_image 自动路由到带 vision 能力的模型F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06scan.rs::ImageRef 现仅采集 alt+srcPhase 2 后可由 commands 层读 base64 喂 vision 抽 description。

与各功能的依赖关系(详见 §4

  • 数据模型 + provider 适配 + 前端渲染 → 不依赖 F-01,可独立落地。
  • vision 自动路由 → 依赖 F-01ModelCapability.modalities未落地前用「provider 配置的 vision 模型名静态探测」过渡。
  • F-06 联动 → 依赖本任务的 ContentPart + provider 适配,不依赖 F-01

1. 现状盘点(先 Read 核实)

1.1 df-ai-coreChatMessage 当前形态

crates/df-ai-core/src/provider.rs:42-83

pub struct ChatMessage {
    pub role: MessageRole,
    pub content: String,              // ← 单字符串,升级目标
    pub tool_call_id: Option<String>,
    pub tool_calls: Option<Vec<ToolCall>>,
    pub model: Option<String>,
    pub status: Option<String>,       // truncated 软删
}

构造器 system/user/assistant/tool_result 全部 content: impl Into<String>,散布于 provider.rs:63-77

1.2 provider content 转换现状

OpenAI 兼容crates/df-ai/src/openai_compat.rs:46-55

struct OpenAiMessage {
    role: String,
    content: String,   // ← 直接透传 ChatMessage.content
    ...
}

convert_requestopenai_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}) —— 纯字符串 contentAnthropic 允许字符串简写,但多模态必须数组 blocks
  • Assistant已构造 content: Vec<text/tool_use 块>anthropic_compat.rs:337-356是数组形态。
  • Toolcontent: 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

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<ChatMessage> 序列化成字符串
rec.messages = messages_json;                         // 落 ai_conversations.messages TEXT 列

df-storage/src/models.rs:158messages: StringJSON array of ChatMessagetruncate_for_persist 阈值 50KBconversation.rs:57

核心约束:升级 content 类型后,反序列化老对话的 {"content":"老文本"} 必须能读出 Vec<ContentPart>[Text],否则历史对话全部炸库。

1.4 前端 ChatMessage 形态

src/api/types.ts:222-231

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

pub struct ImageRef {
    pub alt: String,
    pub src: String,
}

注释scan.rs:326-328明确「采样层只收集 alt+srcPhase 2 上线后由 commands 层读 base64 喂 vision。当前 ChatMessage.content:StringF-260614-05 未做)走纯文本降级」。

collect_samplescan.rs:357-380已把 images: Vec<ImageRef> 挂进 ProjectSamplesrc-tauri/src/commands/project.rs:509-543extract_description_via_llm 当前 build_scan_prompt(&sample, &rule_stack) 构造纯文本 prompt未消费 sample.images

1.6 F-01Phase 1现状

crates/df-ai/src/model_probe.rs / router.rs 均不存在(尚未实施)。但 F-01 设计已定稿2026-06-16F-01-模型能力系统与智能路由设计-2026-06-16.md),定义了 ModelConfig.modalities + ModelRouterhas_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 新增:

/// 多模态消息内容片。Text 片为字符串Image 片可走 url 或 base64二选一二选一非都填#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
    /// 文本片
    Text { text: String },
    /// 图片片。
    /// - urlhttp(s) 可达 URLprovider 直接转发,不读字节)。
    /// - base64data URI 之外的纯 base64 字符串 + media_typeprovider 内嵌转发)。
    ///   二选一base64 非空时 url 忽略,便于前端上行无网络回拉的本地粘贴图。
    Image {
        url: Option<String>,
        base64: Option<String>,
        /// base64 模式必填image/png | image/jpeg | image/webp | image/gif        /// url 模式可空provider 从 URL 后缀嗅探)。
        media_type: Option<String>,
        /// 可选 altF-06 README 图引用回填vision 模型/降级文本时用)
        #[serde(skip_serializing_if = "Option::is_none")]
        alt: Option<String>,
    },
}

ChatMessage.content 改:

pub struct ChatMessage {
    pub role: MessageRole,
    pub content: Vec<ContentPart>,
    ...
}

2.2 向后兼容untagged 联合反序列化

老 JSON {"content":"老文本"} 必须读成 content: vec![ContentPart::Text{text:"老文本"}]。两个方案:

方案 A推荐自定义 deserializeString → 单 Text 片

fn deserialize_content<'de, D>(d: D) -> Result<Vec<ContentPart>, 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::<Vec<ContentPart>>(v).map_err(D::Error::custom)
        }
        other => Err(D::Error::custom(format!("content 期望 string 或 array, 得 {}", other))),
    }
}

字段加 #[serde(deserialize_with = "deserialize_content", default)]

方案 B否决untagged enumContent(String) | Parts(Vec) —— untagged 反序列化歧义大、报错不直观、调试成本高pass。

正向序列化:恒输出数组形态(content:[{type:"text",text:"..."}]),新写入的 JSON 永远是数组;老 String 形态仅在反序列化输入兼容。这意味着 升级后落库的新对话 JSON 结构变了,但反序列化双向兼容,无需迁移脚本。

2.3 构造器升级

provider.rs:63-77 五个构造器签名改 impl Into<Vec<ContentPart>> 不现实(散落调用点太多)。改用两层:

impl ChatMessage {
    // 老调用点零改动String → 单 Text 片
    pub fn system(content: impl Into<String>) -> 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<ContentPart>) -> Self { ... }
}

调用点:老 ChatMessage::user("文本") 不动;新粘贴图片处用 user_parts(vec![Text{...}, Image{...}])

2.4 辅助方法

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::<Vec<_>>().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.contentVec<ContentPart>

  • Text 片:对每个 Text 片单独跑 truncate_for_persist(保留原头尾截断语义)。
  • Image 片base64 字符串通常已 50KB+落库前替换为占位 Text 片 "<image: base64 已省略, 共 N 字节>",避免大体量图把对话 JSON 撑爆(一张 1MB PNG 的 base64 ≈ 1.3MB 字符串)。
  • 重生成/重发时,内存真相源 ContextManager 仍持原始 Image 片(截断只作用于持久化副本,对齐现有「持久化视图不污染内存真相源」约定)。

3. provider 适配

3.1 OpenAI 兼容openai_compat.rs

OpenAiMessage.content 类型 String → serde_json::Value(或 Vec<OpenAiContentPart>

#[derive(Serialize)]
#[serde(untagged)]
enum OpenAiContent {
    Text(String),                              // 纯文本简写(无图时保持现状兼容老端点)
    Parts(Vec<OpenAiContentPart>),
}

#[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_requestopenai_compat.rs:301-333转换

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}),改:

MessageRole::User => {
    Self::flush_tool_results(...);
    let blocks: Vec<serde_json::Value> = 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 之前扫一遍 messagesImage{url:Some, base64:None} 就 fetch + base64 编码回填。

3.3 非 vision 模型降级

provider 配置(AiProviderRecord.default_model / models当前无能力元数据F-01 未做)。降级策略:

过渡方案F-01 未落地):在 AiProviderRecord.config JSON 里加可选字段 vision_models: Vec<String>(用户在 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<Modality>Vision 时路由器才把带图消息路由到该模型;纯文本模型根本收不到带图消息,降级路径仅作 fallback 兜底(用户手动 override 到非 vision 模型时)。

3.4 stream/响应路径不变

OpenAI/Anthropic 流式响应只回文本 deltavision 模型生成文本,不回图),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-06vision 路由的「按能力自动选模型」留到 F-01 落地后接入;过渡期靠静态白名单+手动 override 让多模态可用。两者实施顺序无强约束,但建议 F-01 先行或并行——否则用户要手维护 vision 模型清单,体验割裂。


5. 前端改造(本任务仅调研,不改 code

5.1 类型对齐src/api/types.ts:222

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 }} → 改为遍历 partsText 片插值 + Image 片 <img> 渲染base64 拼 data: URI 或 url 直 src
  • assistant markdown 渲染assistant 消息恒单 Text 片,取 parts[0].text 走原 markdown 路径,零回归。
  • 导出markdown/json/txt遍历 partsText 片 joinImage 片输出 ![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 上限 ~16MBAnthropic 单 image 推荐 < 5MB多图叠加易超限。
  • 多图上限:单条消息 ≤ 4 张(对齐主流 vision 模型单轮建议 + token 成本)。
  • 格式白名单png / jpeg / webp / gifgif 取首帧,对齐 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 tokensdetail:low 765 tokensdetail: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) schemefile://(防读任意本地文件)/ftp:// 等;超时 10sbody 上限 10MB。

7. 实施分阶段与文件改动点

本任务仅设计,下列为后续实施清单。

阶段 1数据模型df-ai-core不依赖 F-01

文件 改动
crates/df-ai-core/src/provider.rs 新增 ContentPart enumChatMessage.content: Vec<ContentPart>;自定义 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()

单测:反序列化老 JSONcontent:"x")→ vec![Text{x}];序列化新结构 → 数组形态round-trip。

阶段 2provider 适配df-ai依赖阶段 1

文件 改动
crates/df-ai/src/openai_compat.rs OpenAiMessage.content: OpenAiContentuntagged Text/Partsconvert_request 按 has_image 分支;无图走字符串简写保持兼容;有图走 Parts 含 image_url默认 detail:low
crates/df-ai/src/anthropic_compat.rs User 消息 content blocks 化Image 片 → source.base64url 模式由 commands 层预拉填 base64provider 不发额外 HTTP
src-tauri/src/commands/ai/agentic.rs(或抽 helper 新增 resolve_image_urls(&mut Vec<ChatMessage>):遇 Image{url:Some,base64:None} 拉 HTTP/读本地 → base64 + media_type 回填;限制 scheme/超时/大小
src-tauri/src/commands/ai/conversation.rs truncate_for_persist 改作用于 Vec<ContentPart>Text 片单独截断、Image 片替换占位 Text 片§2.5
crates/df-storage/src/models.rs AiProviderRecord.config JSON 约定加可选 vision_models: Vec<String>过渡方案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 用户消息渲染改遍历 partsText 插值 + Image <img>);输入区 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 图片上限/格式/数量校验文案

阶段 4vision 路由(依赖 F-01

文件 改动
crates/df-ai/src/router.rsF-01 新建) TaskRequirementsmodalities: Vec<Modality>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 是「自动选模型」锦上添花。

阶段 5F-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 长边,对齐 AnthropicOpenAI 也受益降 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 记录或待补:

  • 「分阶段实施路线」§(行 134-138Phase 1 不改 content / Phase 2 多模态 / Phase 3 Agent 内路由 —— 本文档展开 Phase 2。
  • 新增决策(建议补入功能决策记录,由后续会话触发 decision-record 技能):
    1. content 向后兼容用自定义 deserializeString→单 Text 片),否决 untagged enum。
    2. 无图消息 content 恒字符串简写,不强行数组化(避免纯文本端点回归)。
    3. Image base64 落库前替换占位 Text 片(不存 blob 表,增量价值低于复杂度)。
    4. Anthropic URL 模式 commands 层预拉 base64provider 不发额外 HTTP
    5. F-05 与 F-01 解耦:前 3 阶段独立可跑,阶段 4 路由依赖 F-01。

10. 不做的事(显式排除)

  • 不做视频/音频片ContentPart 只 Text/Image
  • 不做图片编辑(裁剪/标注)——前端原图上行。
  • 不做 base64 blob 独立表§6.5 推迟)。
  • 不做 OCR fallbackvision 模型自带文字识别能力)。
  • 不做流式图片输出vision 模型只回文本 delta
  • 不改 AiMessage.id/role/timestamp 等非 content 字段。
  • 不碰 F-01 的 ModelCapability 数据模型(属 F-01 范畴)。
  • 本文档不实施任何 code纯设计