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

528 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# F-260614-05 模型能力系统 Phase 2多模态实施方案设计
> 状态:📐 设计定稿2026-06-16未实施
> 类型:架构设计文档(不碰任何 code
> 关联F-260614-01Phase 1 模型能力,未实施)/ F-06 导入历史项目(已留 ImageRef 接口)
> 决策记录:见 [功能决策记录-2026-06-14.md](../滚动规范/功能决策记录-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.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-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`)落地后,由 `ModelRouter``has_image` 自动路由到带 vision 能力的模型F-05 自身可在 F-01 未落地时先做「provider 静态白名单探测」独立跑通,最后接 F-01。同步解锁 F-06`scan.rs::ImageRef` 现仅采集 alt+srcPhase 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-coreChatMessage 当前形态
`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})` —— 纯字符串 contentAnthropic 允许字符串简写,但多模态必须数组 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` 阈值 50KBconversation.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+srcPhase 2 上线后由 commands 层读 base64 喂 vision。当前 ChatMessage.content:StringF-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-01Phase 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 },
/// 图片片。
/// - 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` 改:
```rust
pub struct ChatMessage {
pub role: MessageRole,
pub content: Vec<ContentPart>,
...
}
```
### 2.2 向后兼容untagged 联合反序列化
老 JSON `{"content":"老文本"}` 必须读成 `content: vec![ContentPart::Text{text:"老文本"}]`。两个方案:
**方案 A推荐自定义 deserializeString → 单 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 流式响应只回文本 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
```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 }}` → 改为遍历 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)` scheme`file://`(防读任意本地文件)/`ftp://` 等;超时 10sbody 上限 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。
### 阶段 2provider 适配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 层预拉填 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.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 是「自动选模型」锦上添花。
### 阶段 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<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-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纯设计