# df-ai — AI 集成模块

> 创建: 2026-06-10 | 最后更新: 2026-06-14

---

## 概述

`df-ai` 是 DevFlow 的 AI 核心层，提供 LLM Provider 抽象、双协议实现（OpenAI 兼容 + Anthropic）、流式 SSE 解析、工具调用、上下文窗口管理和 embedding 支持。

---

## 当前状态

| 能力 | 状态 |
|------|------|
| LlmProvider trait | ✅ |
| OpenAI 兼容 Provider（流式/非流式/embed）| ✅ |
| Anthropic Provider（流式）| ✅ Sprint 8 |
| ContextManager（分组滑窗）| ✅ Sprint 11 |
| embed() 向量生成 | ✅ Sprint 15 |
| AiToolRegistry（基础设施）| ✅ Sprint 5（19 工具注册在 commands/ai/tool_registry.rs）|
| coordinator | ⬜ 空壳（B 路线待填）|

---

## 文件结构

```
crates/df-ai/src/
├── lib.rs              — 公共导出 + build_provider 工厂（按协议选 OpenAICompatProvider / AnthropicCompatProvider）
├── provider.rs         — LlmProvider trait（含 embed 默认实现 + endpoint 默认实现）
├── openai_compat.rs    — OpenAI 兼容实现（chat + embed）
├── anthropic_compat.rs — Anthropic Messages API 实现（Sprint 8）
├── context.rs          — ContextManager 分组滑动窗口（Sprint 11）
├── ai_tools.rs         — AiToolRegistry + AiTool + RiskLevel（仅基础设施；具体工具定义在 src-tauri/commands/ai/tool_registry.rs）
└── coordinator.rs      — AgentCoordinator 空壳（B 路线，有意保留勿删）
```

> 注：历史文档曾列 `router.rs`（ModelRouter）与 `stream.rs`（StreamCollector）两个文件，实际均不存在（已删，见全量核对报告 §3）。


---

## LlmProvider Trait

```rust
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;

#[async_trait]
pub trait LlmProvider: Send + Sync {
    // 非流式完整响应（用于标题生成/知识提炼）
    async fn complete(&self, request: CompletionRequest)
        -> anyhow::Result<CompletionResponse>;

    // 流式 SSE（主对话）
    async fn stream(&self, request: CompletionRequest)
        -> anyhow::Result<StreamResult>;

    // 向量生成（默认 bail，openai_compat 覆盖实现）
    async fn embed(&self, _model: &str, _texts: Vec<String>)
        -> anyhow::Result<Vec<Vec<f32>>> {
        anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
    }

    // Provider 名称（必填）
    fn name(&self) -> &str;

    // 实际请求端点（默认回落 name()，provider 覆盖返真实 URL，供 401/网络错误诊断）
    fn endpoint(&self) -> String {
        self.name().to_string()
    }
}
```

> 注：历史文档曾列 `supported_features() -> ProviderFeatures`（已删），当前 trait 末项为 `endpoint()`。

---

## OpenAI 兼容 Provider（openai_compat.rs）

支持 OpenAI / DeepSeek / GLM / 本地 Ollama 等任意 OpenAI 兼容端点。

| 特性 | 实现 |
|------|------|
| base_url 智能拼接 | `chat_url()` 三分支：已含 `/chat/completions` 直用；以 `/v<数字>` 结尾（如 `/api/paas/v4`，由 `ends_with_version` 判定）补 `/chat/completions`；仅域名（如 `api.openai.com`）补 `/v1/chat/completions` |
| 流式 | `stream: true` + SSE 逐 chunk 解析（`apply_openai_sse` 纯函数）|
| 工具调用 | tool_calls 按 index 排序（消 HashMap 迭代乱序）|
| usage 解析 | `stream_options: {include_usage: true}`，末 chunk 读累计值 |
| finish 判定 | 逐 chunk 判 `finish_reason`：`stop`/`tool_calls`/`length` 三者同等视为正常 finished（`length` = max_tokens 截断，属正常终止而非断连）|
| embed | POST `/v1/embeddings`，响应按 index 排序返回 `Vec<Vec<f32>>` |
| connect timeout | `connect_timeout(30s)`，不设总 timeout（避免误砍流式长任务）|
| complete 单请求超时 | ✅ FR-R4（2026-06-14 commit 36d68dd）：`complete()` 同步路径用 `RequestBuilder::timeout(Duration::from_secs(60))` 设 60s 单请求超时；**仅作用于 complete，不影响 stream 流式路径**（stream 仍靠上层 idle timeout 兜底）|

> 注：idle timeout（120s）与断连丢弃（finished_received）属上层 `stream_llm`（src-tauri/commands/ai.rs）的职责，不在本 Provider 层。

SSE 解析抽成 `pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk` 纯函数，与 HTTP/eventsource 解耦，便于单测（喂构造 data 字符串验证 `[DONE]`/usage 覆盖/tool_calls 等分支）。

---

## Anthropic Provider（anthropic_compat.rs，Sprint 8）

| 特性 | 实现 |
|------|------|
| 认证 | `x-api-key` + `anthropic-version: 2023-06-01` |
| 消息格式 | 顶层 `system` 字段，`max_tokens` 必填（请求缺省时兜底 `DEFAULT_MAX_TOKENS = 4096`）|
| 流式 SSE | event 类型解析（全集见下）|
| 工具调用 | `content_block` type=tool_use |
| usage | `message_start` 初始化累加器(input_tokens)、`message_delta` 覆盖 completion(output_tokens 为累计值，非增量)、`message_stop` 经 `take()` 带出累积 usage |
| embed | 不支持（Anthropic 无 embed API），继承 trait 默认 Err |

SSE 事件全集（按 `type` 字段分发）:

| event type | 处理 |
|------------|------|
| `message_start` | 用 `message.usage.input_tokens` 初始化累加器（output 置 0）|
| `message_delta` | `usage.output_tokens` 是累计值（非增量），直接覆盖 completion 并重算 total |
| `content_block_delta`(text_delta) | 文本增量 |
| `content_block_delta`(input_json_delta) | 工具入参增量（带 index）|
| `content_block_start`(tool_use) | 工具块开始，带 id + name |
| `message_stop` | 返回 `finished=true` 终态 chunk，usage 经 `take()` 带出 |
| `error` | 返回 `finished=true` 终态空 chunk（不清空累加器）|
| 其它（content_block_stop / ping）| 空 chunk |

SSE 解析抽成 `pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk` 纯函数，与 HTTP/eventsource 解耦，便于单测（喂构造 data 字符串验证事件分支与 usage 累积）。

支持 GLM 订阅端点（`https://open.bigmodel.cn/api/anthropic`）。

### tool_use_id 兜底（AC1/AC2，2026-06-14 commit 36d68dd）

GLM 端对 `tool_use_id` 为 `None`/空串的 `tool_result` 块会返 500 卡死会话。两路兜底：

| 路径 | 入站/出站 | 兜底 |
|------|----------|------|
| 出站（请求构造）| tool_result 块 | `tool_call_id` 为 None/空时**跳过该块** + `tracing::warn!`（不发出无效块）|
| 入站（SSE 流式）| tool_use 块 | 缺 id 时填占位 id `tool_missing_{idx}`（idx 为 content_block index）+ `tracing::warn!`；同步路径（complete）缺 id 时跳过 |

> 两者均 warn 留痕，不静默吞掉，便于事后排查 provider 返回异常 tool_use 的场景。

---

## ContextManager 分组滑动窗口（context.rs，Sprint 11）

解决长对话无限增长导致 `context_length_exceeded` 死锁。

### 核心设计

- **TokenEstimator**：字符粗估，零依赖，保守 ±15%。单条公式 = `content(chars×0.35 ceil)` + `per_message 4` + 每个 `tool_call(per_tool_call 30 + name/arguments 各按 chars×0.35 ceil)` + 有 `tool_call_id` 时 `+3`
- **ContextConfig**：max_tokens 128k / output_reserve 8192 / safety 0.85；预算公式 `(max_tokens − output_reserve) × safety_ratio`，由 `ContextConfig::budget_limit()` 提供（`ContextManager::budget_limit()` 仅转发它）
- **分组淘汰单元**：工具调用三元组（ToolCallHead + ToolResultTail* + 紧随文本 Assistant）原子性同进同出，防上下文语义断裂
- **保护区**：`PROTECT_COUNT = 6`（编译期常量，≈ 最近 2 个完整用户轮次），最后 6 条消息永不裁
- **裁剪范围**：仅影响发送视图（`build_for_request`），全量历史（`all_messages_clone`）用于持久化

### 关键方法

| 方法 | 用途 |
|------|------|
| `push(message: ChatMessage)` | 追加消息并计 token、更新 `history_tokens` 缓存（push 不裁剪，裁剪统一在 build_for_request）|
| `clear()` | 清空消息历史并重置 token 计数 |
| `len() -> usize` | 历史消息条数 |
| `is_empty() -> bool` | 历史是否为空 |
| `history_tokens() -> u32` | 当前历史累计 token（不含 system prompt）|
| `budget_limit() -> u32` | 上下文预算上限，转发 `config.budget_limit()` = (max_tokens − output_reserve) × safety_ratio |
| `iter() -> impl Iterator<Item = &ChatMessage>` | 只读迭代历史消息（如标题生成 filter）|
| `build_for_request(sys_tokens) -> (Vec<ChatMessage>, bool)` | 返回裁剪后的消息列表 + 是否发生裁剪（用于 LLM 调用）|
| `all_messages_clone()` | 返回全量消息（用于 save_conversation / 标题生成）|
| `restore_from_messages(msgs)` | 切换对话时重建 ContextManager 缓存 |
| `replace_tool_result_content(tool_call_id, new_content) -> bool` | 原子更新工具结果内容（审批通过/拒绝时回填），返回是否找到并替换。✅ FR-D4（2026-06-14 commit 4a95f6a）：由正向 `position` 遍历改为反向 `rposition`（审批替换命中最近一条同 id 工具结果；该调用属审批低频路径，不引入索引）|

---

## AI 工具注册

> 归属：`ai_tools.rs` 仅提供基础设施（`RiskLevel` / `AiTool` / `AiToolRegistry`）；工具的具体定义与注册在 `src-tauri/src/commands/ai/tool_registry.rs::build_ai_tool_registry`，handler 即唯一执行路径（schema+risk+实现同源）。

22 个内置工具，按风险分级（核对 `build_ai_tool_registry`，2026-06-15）：

| 风险 | 数量 | 工具 |
|------|------|------|
| Low（自动执行，8）| 8 | list_projects / list_tasks / list_ideas / list_trash / read_file / list_directory / file_info / search_files |
| Medium（需审批，9）| 9 | update_project / create_project / bind_directory / create_task / update_task / create_idea / write_file / patch_file / append_file |
| High（需审批，5）| 5 | delete_task / delete_project / restore_project / purge_project / run_command |

> 原文档写 "21 个（Low 8 + Medium 8 + High 5）"，遗漏了 **patch_file**（Sprint 后增补的 Medium 风险局部文件编辑工具）。实际为 **22 个（Low 8 + Medium 9 + High 5）**。

工具执行结果写 `ai_tool_executions` 表（审计日志）。

### run_command（Sprint 21，方案 A「直接暴露 Shell」）

让 AI 形成「写(write_file)→跑(run_command)→看 stdout→改」闭环。handler 复用 `df_execute::shell::execute`（跨平台：Windows `cmd /C` / Unix `sh -c`，已封装 `tokio::time::timeout` + `kill_on_drop` 防僵尸进程）。

| 维度 | 设计 |
|------|------|
| 风险 | **High**——强制人工审批，审批卡显示 `command`+`working_dir`（复用 ToolCard `toolArgsEntries`，零前端改动） |
| 参数 | `command`(必填) / `working_dir`(可选,默认 workspace_root) / `timeout_secs`(可选,默认 60) |
| working_dir 校验 | 走 `validate_path` 黑名单（`..` + `.ssh`/`.aws`/`windows`/`system32` 等），**不走** `resolve_workspace_path` 越界校验——High risk 靠人审兜底，放开目录才能在用户任意项目跑命令 |
| 输出截断 | stdout/stderr 各 10KB，尾部保留（报错堆栈在末尾），超出设 `truncated: true`（`truncate_output` 辅助函数，char 边界安全截） |
| 安全边界 | A 方案=最高风险，唯一防线=人审+黑名单。未做命令黑名单(`rm -rf`/`format`)、网络外传检测、资源限制——属 B(ScriptNode)/C(Docker)/D(MCP) 方案领域 |

---

## 知识库集成（Sprint 15，逻辑在 src-tauri/commands/ai.rs）

### 知识注入

`build_knowledge_context(state, query, config) -> String`：
- `config.auto_inject == false` → 立即返回空（零开销）
- `hybrid_search()` top-3（LIKE 或 LIKE+向量混合）
- 每条命中调 `increment_reuse_count`（fire-and-forget）
- 格式化为 markdown，注入 system prompt 头部

### 知识提炼

`extract_knowledge_from_conversation(db, conv_id, provider_cfg)`：
- 后台 spawn（`tauri::async_runtime::spawn`），提炼失败仅 warn 不阻断
- 取最后 6 条 user/assistant 消息 → LLM JSON 输出（强制 JSON schema）
- parse 失败整批丢弃；成功则逐条写 candidate

`maybe_spawn_extraction(...)`：agentic loop 两处正常退出路径统一调用。

### 向量 embedding（Phase 5.5）

`generate_embedding(state, text, config) -> Option<Vec<f32>>`：
- 截断 8000 字 → 找 `embedding_provider_id` 对应 provider → `embed()`
- 失败返回 None（降级 LIKE）

`hybrid_search(state, query, limit, config)`：三层降级链：
1. `vector_enabled == false` → 纯 LIKE
2. embed 调用失败 → 纯 LIKE
3. 正常 → 双信号合并排序（同时 LIKE+向量 cos≥0.3 > 仅 LIKE > 仅向量 cos≥0.3）

---

## LLM 并发控制（state.rs，Sprint 11 Part C）

`LlmConcurrency`：双层 Semaphore，运行时可调。

| Semaphore | 默认 permits | 限流对象 |
|-----------|------------|---------|
| global | 3 | 全部 LLM 调用（stream_llm / 标题 / 提炼）|
| per_conv | 2 | 单对话并发 |

本地工具执行（`tools.execute`）不限流，无外部成本。

---

## 决策能力缺口（B 路线，待立项）

| 能力 | 现状 | 需补 |
|------|------|------|
| Planning（任务规划）| 缺失 | 意图 → LLM 规划子任务 DAG |
| Coordinator（多 agent）| `coordinator.rs::run()` 全 TODO | 实现多 agent 协作拆 DAG |
| Conditions（条件分支）| 缺失（df-ai 无实现，条件求值属 df-workflow crate）| JSON Path / 比较 / and-or-not |
| Reflection（自纠）| 缺失 | 执行后自检 / 重试 |

详见 [Phase 2 计划 - 决策能力升级](../07-项目管理/Phase2计划-2026-06-12.md)。

---

## 相关文档

- [df-storage 存储层](./df-storage-存储层-2026-06-12.md)
- [df-workflow 工作流引擎](./df-workflow-工作流引擎-2026-06-12.md)
- [df-nodes 节点集合](./df-nodes-节点集合-2026-06-12.md)
