# 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<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.2（`deserialize_content` String→单 Text 片）、§2.3（构造器签名 `impl Into<String>` 改 `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<ContentPart>{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<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`）：
```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<text/tool_use 块>`（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<ChatMessage> 序列化成字符串
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<ContentPart>[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<ImageRef>` 挂进 `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<String>,
        base64: Option<String>,
        /// base64 模式必填（image/png | image/jpeg | image/webp | image/gif）。
        /// url 模式可空（provider 从 URL 后缀嗅探）。
        media_type: Option<String>,
        /// 可选 alt（F-06 README 图引用回填，vision 模型/降级文本时用）
        #[serde(skip_serializing_if = "Option::is_none")]
        alt: Option<String>,
    },
}
```

`ChatMessage.content` 改：

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

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

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

**方案 A（推荐）：自定义 deserialize，String → 单 Text 片**

```rust
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 enum（`Content(String) | Parts(Vec)）`** —— untagged 反序列化歧义大、报错不直观、调试成本高，pass。

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

### 2.3 构造器升级

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

```rust
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 辅助方法

```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::<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.content` 是 `Vec<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>`）：

```rust
#[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_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<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` 之前扫一遍 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<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 流式响应只回文本 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 片 `<img>` 渲染（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<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()` |

**单测**：反序列化老 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<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` | 用户消息渲染改遍历 parts（Text 插值 + 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 | 图片上限/格式/数量校验文案 |

### 阶段 4：vision 路由（依赖 F-01）

| 文件 | 改动 |
|---|---|
| `crates/df-ai/src/router.rs`（F-01 新建） | `TaskRequirements` 含 `modalities: 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 是「自动选模型」锦上添花。

### 阶段 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<ContentPart> 版本。
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（纯设计）。
