From 5364ca004c75db525389b2e9a25b373c40c4a6c7 Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Fri, 17 Jul 2026 11:19:22 +0800 Subject: [PATCH] =?UTF-8?q?ADD:=20=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 47 ++++++++ crates/focus-cli/src/auth.rs | 37 ++++++ crates/focus-cli/src/cli.rs | 21 +++- crates/focus-cli/src/config.rs | 122 ++++++++++++++++++-- crates/focus-cli/src/login.rs | 41 ++++++- crates/focus-cli/src/main.rs | 10 ++ crates/focus-cli/src/repl.rs | 53 ++++++++- crates/focus-cli/src/runner.rs | 44 ++++++- crates/focus-core/src/agent.rs | 59 ++++++++++ crates/focus-core/src/error.rs | 12 ++ crates/focus-core/src/event.rs | 19 +++ crates/focus-core/src/json.rs | 12 ++ crates/focus-core/src/lib.rs | 20 ++++ crates/focus-core/src/mock.rs | 12 ++ crates/focus-core/src/model.rs | 115 ++++++++++++++++++ crates/focus-core/src/provider.rs | 63 +++++++++- crates/focus-core/src/tool.rs | 63 ++++++++++ crates/focus-harness/src/lib.rs | 2 + crates/focus-json/src/error.rs | 11 ++ crates/focus-json/src/lib.rs | 12 ++ crates/focus-json/src/parser.rs | 57 +++++++++ crates/focus-json/src/value.rs | 46 ++++++++ crates/focus-json/src/writer.rs | 12 ++ crates/focus-providers/src/anthropic.rs | 67 +++++++++++ crates/focus-providers/src/common.rs | 28 +++++ crates/focus-providers/src/config_value.rs | 20 +++- crates/focus-providers/src/lib.rs | 7 +- crates/focus-providers/src/openai_compat.rs | 73 +++++++++++- crates/focus-providers/src/zai.rs | 118 ++++++++++++++++--- crates/focus-tools/src/lib.rs | 4 + crates/focus-transport/src/error.rs | 13 +++ crates/focus-transport/src/http.rs | 54 ++++++++- crates/focus-transport/src/lib.rs | 22 ++++ crates/focus-transport/src/sse.rs | 38 +++++- crates/focus-transport/src/tls.rs | 13 +++ crates/focus-transport/src/transport.rs | 30 ++++- 36 files changed, 1330 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 736e41d..8830261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,53 @@ > - [`01-types-and-protocol.md`](./docs/architecture/01-types-and-protocol.md) — 类型契约精读 > - [`02-agent-loop.md`](./docs/architecture/02-agent-loop.md) — 核心循环精读 +## 0. 代码注释规范(最高优先级 / Top-Priority Comment Rule) + +> 本节规则凌驾于其它一切约定之上。任何新增或修改的源代码(`.rs` 文件)**必须**同时带有**中文**与**英文**注释;缺少任一语言即视为不合规。 + +> The rule in this section overrides every other convention below. All newly added or modified source code (`.rs` files) **MUST** carry **both Chinese and English** comments; missing either language is a violation. + +### 0.1 双语注释要求 / Bilingual Comment Requirement + +- **所有**模块级文档注释(`//!`)、项级文档注释(`///`)以及行内/块注释(`//`)都必须**中英双语并存**。 +- 推荐写法:先写中文,再写英文(或反之),两者表达同一含义即可,不要求逐字对译。 + +- **All** module-level doc comments (`//!`), item-level doc comments (`///`), and inline/block comments (`//`) **MUST** appear in **both Chinese and English**. +- Recommended style: write Chinese first, then English (or vice versa). They must convey the same meaning; verbatim word-for-word translation is not required. + +### 0.2 示例 / Examples + +模块文档 / Module doc: + +```rust +//! Agent 核心循环:实现 pi 风格的双层循环(steering + follow-up)。 +//! Core agent loop: implements the pi-style double loop (steering + follow-up). +``` + +项文档 / Item doc: + +```rust +/// 从流中拉取下一个事件;返回 `None` 表示流结束。 +/// Pull the next event from the stream; `None` means end-of-stream. +pub fn next_event(&mut self) -> Option { ... } +``` + +行内注释 / Inline comment: + +```rust +// 先尝试从缓冲区读取,避免无谓的 syscall。 +// Try the buffer first to avoid a pointless syscall. +let b = self.buf.pop_front(); +``` + +### 0.3 适用范围 / Scope + +- ✅ 适用:所有 `crates/**/src/**/*.rs`(含 `lib.rs`、`main.rs`)、`tests/**/*.rs` 中的公开项与关键私有项。 +- ❌ 不适用:自动生成的代码、`build.rs` 中的纯样板、纯粹的格式占位空行。 + +- ✅ In scope: all public items and key private items in `crates/**/src/**/*.rs` (including `lib.rs`, `main.rs`) and `tests/**/*.rs`. +- ❌ Out of scope: auto-generated code, pure boilerplate in `build.rs`, empty placeholder lines. + ## 1. 项目定位 `focus` 是参考 [pi](https://github.com/earendil-works/pi)(TypeScript 原版)和 diff --git a/crates/focus-cli/src/auth.rs b/crates/focus-cli/src/auth.rs index 8a2bd29..9167a8a 100644 --- a/crates/focus-cli/src/auth.rs +++ b/crates/focus-cli/src/auth.rs @@ -1,10 +1,16 @@ +//! 凭据存储——focus 版的 pi `auth.json`。 //! Credential store — the focus equivalent of pi's `auth.json`. //! +//! 以 `0600` 权限(父目录 `0700`)将每个 provider 的 API key 存储在 +//! `~/.focus/auth.json`,镜像 pi 的 `FileAuthStorageBackend`。值原样存储 +//! (字面量、`$VAR` 或 `${VAR}`),在读取时用 +//! [`focus_providers::resolve_config_value`] 解析。 //! Stores per-provider API keys in `~/.focus/auth.json` with mode `0600` (and //! parent directory `0700`), mirroring pi's `FileAuthStorageBackend`. Values //! are stored verbatim (literal, `$VAR`, or `${VAR}`) and resolved with //! [`focus_providers::resolve_config_value`] at read time. //! +//! 结构(一个 `Record`): //! Shape (a `Record`): //! ```json //! { @@ -18,10 +24,15 @@ use focus_json::{parse, to_string, JsonValue}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +/// 凭据存储的错误。 /// Errors from the auth store. #[derive(Debug)] pub enum AuthError { + /// I/O 错误。 + /// An I/O error. Io(String), + /// JSON 解析错误。 + /// A JSON parse error. Parse(String), } @@ -36,15 +47,21 @@ impl std::fmt::Display for AuthError { impl std::error::Error for AuthError {} +/// 一条已存储的凭据。仅支持 API key(OAuth 不在范围内)。 /// One stored credential. Only API keys are supported (OAuth is out of scope). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Credential { + /// 凭据种类(目前固定为 `"api_key"`)。 + /// Credential kind (currently always `"api_key"`). pub kind: String, + /// 原样存储——可能是字面量、`$VAR` 或 `${VAR}`。在读取时通过 + /// [`AuthStore::read`] 解析。 /// Stored verbatim — may be a literal, `$VAR`, or `${VAR}`. Resolved on /// read via [`AuthStore::read`]. pub key: String, } +/// auth 文件路径:`/auth.json`。 /// The path to the auth file: `/auth.json`. pub fn auth_path() -> Result { Ok(config_dir() @@ -52,6 +69,7 @@ pub fn auth_path() -> Result { .join("auth.json")) } +/// 确保父目录存在且权限为 0700(仅在 Unix 上生效)。 /// Ensure the parent directory exists with mode 0700, Unix-only effect. fn ensure_parent_dir(dir: &Path) -> Result<(), AuthError> { if !dir.exists() { @@ -65,11 +83,13 @@ fn ensure_parent_dir(dir: &Path) -> Result<(), AuthError> { Ok(()) } +/// 以 0600 权限(Unix)写入文件,并确保父目录权限为 0700。 /// Write a file with mode 0600 (Unix), ensuring the parent dir is 0700. fn write_secret(path: &Path, contents: &str) -> Result<(), AuthError> { if let Some(parent) = path.parent() { ensure_parent_dir(parent)?; } + // 先写入临时文件以保证原子性,再 rename,最后 chmod。 // Write to a temp file then rename for atomicity, then chmod. let tmp = path.with_extension("tmp"); std::fs::write(&tmp, contents).map_err(|e| AuthError::Io(format!("write: {}", e)))?; @@ -82,6 +102,7 @@ fn write_secret(path: &Path, contents: &str) -> Result<(), AuthError> { Ok(()) } +/// 读取原始(未解析)的凭据映射。文件缺失时返回空映射。 /// Read the raw (unresolved) credential map. Missing file → empty map. fn read_raw(path: &Path) -> Result, AuthError> { if !path.exists() { @@ -93,6 +114,8 @@ fn read_raw(path: &Path) -> Result, AuthError> { parse_credential_map(&value) } +/// 将 JSON 值解析为凭据映射。 +/// Parse a JSON value into a credential map. fn parse_credential_map(value: &JsonValue) -> Result, AuthError> { let entries = match value { JsonValue::Obj(e) => e, @@ -110,6 +133,8 @@ fn parse_credential_map(value: &JsonValue) -> Result) -> String { let mut obj = JsonValue::obj(); for (provider, cred) in map { @@ -121,6 +146,7 @@ fn serialize_credential_map(map: &BTreeMap) -> String { to_string(&obj) } +/// 凭据存储。 /// The credential store. #[derive(Debug)] pub struct AuthStore { @@ -128,28 +154,36 @@ pub struct AuthStore { } impl AuthStore { + /// 打开位于 `/auth.json` 的默认存储。 /// Open the default store at `/auth.json`. pub fn open() -> Result { Ok(Self { path: auth_path()? }) } + /// 在显式路径打开存储(用于测试或自定义位置)。 /// Open a store at an explicit path (for tests or custom locations). #[allow(dead_code)] pub fn at(path: PathBuf) -> Self { Self { path } } + /// 底层文件路径。 /// Path of the backing file. pub fn path(&self) -> &Path { &self.path } + /// 列出所有已存储凭据的 provider id。不会解析 key(因此在枚举期间不会触发 + /// 任何 env/命令求值)。 /// List all provider ids that have a stored credential. Does NOT resolve /// keys (so no env/command evaluation happens during enumeration). pub fn list(&self) -> Result, AuthError> { Ok(read_raw(&self.path)?.into_keys().collect()) } + /// 读取某 provider 已解析的凭据。若无存储凭据则返回 `None`。存储的 `key` + /// 模板会通过 [`focus_providers::resolve_config_value`] 解析(即展开 `$VAR`)。 + /// 解析错误被映射为 `None`(按"没有可用 key"处理)。 /// Read the resolved credential for a provider. Returns `None` if no /// credential is stored. The stored `key` template is resolved via /// [`focus_providers::resolve_config_value`] (so `$VAR` is expanded). @@ -169,6 +203,8 @@ impl AuthStore { } } + /// 存储(或替换)某 provider 的凭据。key 原样写入——由调用者决定存字面量 + /// 还是 `$VAR` 引用。 /// Store (or replace) a credential for a provider. The key is written /// verbatim — the caller decides whether to store a literal or a `$VAR` /// reference. @@ -179,6 +215,7 @@ impl AuthStore { write_secret(&self.path, &text) } + /// 删除某 provider 的凭据。若确实删除了则返回 true。 /// Remove a provider's credential. Returns true if a credential was removed. pub fn delete(&self, provider: &str) -> Result { let mut map = read_raw(&self.path)?; diff --git a/crates/focus-cli/src/cli.rs b/crates/focus-cli/src/cli.rs index 46b74a1..5a629e7 100644 --- a/crates/focus-cli/src/cli.rs +++ b/crates/focus-cli/src/cli.rs @@ -1,34 +1,50 @@ +//! 极简命令行参数解析器(不依赖 clap)。 //! Minimal command-line argument parser (no clap dependency). //! +//! 由 `main` 决定的两种模式: //! Two modes, decided in `main`: +//! - **REPL**:不带 prompt 且 stdin 为 TTY 时运行 `focus`。 //! - **REPL**: `focus` with no prompt and stdin is a TTY. +//! - **单次 prompt**:`focus "text"`(或 `echo text | focus`)。 //! - **Single-prompt**: `focus "text"` (or `echo text | focus`). //! +//! `/login` 与 `/logout` 作为斜杠命令位于 REPL 内部。 //! `/login` and `/logout` live inside the REPL as slash commands. use std::env; +/// 解析后的命令行选项(用于单次 prompt 模式;REPL 也会从这里读取 +/// `provider` 与 `api_key` 作为初始值)。 /// Parsed command-line options (for single-prompt mode; the REPL also reads /// `provider` and `api_key` from here for initial values). #[derive(Debug, Clone, Default)] pub struct Cli { + /// prompt 文本。若缺失且 stdin 是 TTY,则进入 REPL。 /// The prompt text. If absent and stdin is a TTY, enter the REPL. pub prompt: Option, + /// 覆盖 provider(否则使用配置中的默认值)。 /// Override the provider (default from config otherwise). pub provider: Option, + /// 覆盖模型。 /// Override the model. pub model: Option, + /// 覆盖系统提示词。 /// Override the system prompt. pub system: Option, + /// 内存中的 API key 覆盖值(不持久化)。类似 pi 的 `--api-key`。 /// In-memory API key override (not persisted). Like pi's `--api-key`. pub api_key: Option, + /// 打印解析后的配置并退出。 /// Print the resolved config and exit. pub print_config: bool, + /// 显示帮助并退出。 /// Show help and exit. pub help: bool, } impl Cli { + /// 用法字符串。 + /// The usage string. pub fn usage() -> &'static str { "focus [OPTIONS] [PROMPT]\n\ \n\ @@ -47,11 +63,14 @@ impl Cli { } } +/// 解析 `std::env::args`。 /// Parse `std::env::args`. pub fn parse() -> Cli { parse_from(env::args().skip(1)) } +/// 从给定的参数可迭代对象解析 CLI(便于测试)。 +/// Parse the CLI from a given iterable of args (useful for tests). pub fn parse_from>(args: I) -> Cli { let mut cli = Cli::default(); let mut args = args.into_iter().peekable(); @@ -67,7 +86,7 @@ pub fn parse_from>(args: I) -> Cli { s if s.starts_with("--model=") => cli.model = Some(s[8..].to_string()), s if s.starts_with("--system=") => cli.system = Some(s[9..].to_string()), s if s.starts_with("--api-key=") => cli.api_key = Some(s[10..].to_string()), - s if s.starts_with('-') => { /* ignore unknown flags */ } + s if s.starts_with('-') => { /* 忽略未知标志 / ignore unknown flags */ } _ => { if cli.prompt.is_none() { cli.prompt = Some(arg); diff --git a/crates/focus-cli/src/config.rs b/crates/focus-cli/src/config.rs index 34c892c..fdecf44 100644 --- a/crates/focus-cli/src/config.rs +++ b/crates/focus-cli/src/config.rs @@ -1,9 +1,14 @@ +//! 配置加载。 //! Configuration loading. //! +//! 读取 `~/.focus/config.json`(可通过 `FOCUS_CONFIG_DIR` 覆盖)。该文件列出 +//! provider 与一个默认值;API-key 的值可以是 `$VAR`/`${VAR}` 模板,在加载时 +//! 解析(见 [`focus_providers::resolve_config_value`])。 //! Reads `~/.focus/config.json` (overridable via `FOCUS_CONFIG_DIR`). The file //! lists providers and a default; API-key values may be `$VAR`/`${VAR}` //! templates resolved at load time (see [`focus_providers::resolve_config_value`]). //! +//! 示例配置: //! Example config: //! ```json //! { @@ -13,20 +18,36 @@ //! "kind": "zai", //! "region": "cn", //! "model": "glm-5.2", +//! "path": "/api/coding/paas/v4/chat/completions", //! "apiKey": "$ZAI_API_KEY" //! } //! } //! } //! ``` +//! +//! `path` 字段用于选择接入档位:对 `xxxxxxxx.yyyyyyyy` 格式的 key 使用 GLM +//! Coding Plan(`/api/coding/paas/v4/chat/completions`),对按量付费 key 使用 +//! 标准 PaaS(`/api/paas/v4/chat/completions`)。省略时使用 provider 的默认路径。 +//! The `path` field selects the access tier: the GLM Coding Plan +//! (`/api/coding/paas/v4/chat/completions`) for `xxxxxxxx.yyyyyyyy` keys, or +//! the standard PaaS (`/api/paas/v4/chat/completions`) for pay-as-you-go keys. +//! When omitted, the provider's default path is used. use focus_json::{parse, JsonValue}; use std::path::{Path, PathBuf}; +/// 加载配置时出现的错误。 /// Errors while loading config. #[derive(Debug)] pub enum ConfigError { + /// I/O 错误(例如读取文件失败)。 + /// An I/O error (e.g. failed to read the file). Io(String), + /// JSON 解析错误。 + /// A JSON parse error. Parse(String), + /// 缺少必需字段。 + /// A required field is missing. Missing(String), } @@ -42,26 +63,49 @@ impl std::fmt::Display for ConfigError { impl std::error::Error for ConfigError {} +/// 已解析的 provider 条目。 /// A resolved provider entry. #[derive(Debug, Clone)] pub struct ProviderEntry { + /// provider 种类(如 `zai`、`openai`、`anthropic`)。 + /// Provider kind (e.g. `zai`, `openai`, `anthropic`). pub kind: String, + /// 国内为 `"cn"`,国际为 `"intl"`(仅 zai 适用)。 /// `"cn"` for domestic, `"intl"` for international (zai only). pub region: String, + /// 默认模型 id。 + /// The default model id. pub model: String, + /// 已解析的 API key(加载时由 `$VAR`/`${VAR}` 模板展开而来)。 + /// Resolved API key (expanded from `$VAR`/`${VAR}` templates at load time). pub api_key: String, + /// 可选的自定义 base host(覆盖区域默认值)。 /// Optional custom base host (overrides the region default). pub base_url: Option, + /// 可选的 chat-completions 路径覆盖(例如为 `xxxxxxxx.yyyyyyyy` 格式的 key + /// 指定 GLM Coding Plan 路径 `/api/coding/paas/v4/chat/completions`)。 + /// 未设置时使用 provider 的默认路径。 + /// Optional chat-completions path override (e.g. the GLM Coding Plan path + /// `/api/coding/paas/v4/chat/completions` for `xxxxxxxx.yyyyyyyy` keys). + /// When unset, the provider's default path is used. + pub path: Option, } +/// 已加载的配置。 /// The loaded configuration. #[derive(Debug, Clone)] pub struct Config { + /// 默认 provider 名称。 + /// The default provider name. pub default: String, + /// 全部 provider 条目(名称, 条目)的有序列表。 + /// All provider entries as an ordered list of (name, entry). pub providers: Vec<(String, ProviderEntry)>, } impl Config { + /// 一个没有任何 provider 的空配置。当配置文件尚不存在时作为回退, + /// 这样 CLI 可以打印引导信息而不是崩溃。 /// An empty config with no providers. Used as a fallback when the config /// file does not exist yet, so the CLI can print guidance instead of /// crashing. @@ -72,11 +116,13 @@ impl Config { } } + /// 未配置任何 provider 时为真。 /// True if no providers are configured. pub fn is_empty(&self) -> bool { self.providers.is_empty() } + /// 返回默认 provider 条目;若默认值未设置或未知则返回 None。 /// Get the default provider entry, or None if the default is unset/unknown. #[cfg(test)] pub fn default_provider(&self) -> Option<(&str, &ProviderEntry)> { @@ -86,6 +132,7 @@ impl Config { .map(|(n, e)| (n.as_str(), e)) } + /// 按名称获取 provider 条目。 /// Get a named provider entry. pub fn provider(&self, name: &str) -> Option<&ProviderEntry> { self.providers @@ -95,6 +142,7 @@ impl Config { } } +/// 解析配置目录:`$FOCUS_CONFIG_DIR` 或 `~/.focus`。 /// Resolve the config directory: `$FOCUS_CONFIG_DIR` or `~/.focus`. pub fn config_dir() -> Result { if let Ok(dir) = std::env::var("FOCUS_CONFIG_DIR") { @@ -104,8 +152,12 @@ pub fn config_dir() -> Result { Ok(PathBuf::from(home).join(".focus")) } +/// 从默认路径(`~/.focus/config.json`)加载配置。 /// Load config from the default path (`~/.focus/config.json`). /// +/// 若文件不存在,则返回内置默认 provider(这样 CLI 开箱即用: +/// `focus login zai` + `focus "你好"`)。文件存在但不可读、格式错误或缺少 +/// 必需字段时仍视为错误。 /// If the file does not exist, returns the built-in default providers (so the /// CLI works out of the box: `focus login zai` + `focus "你好"`). A file that /// exists but is unreadable, malformed, or missing required fields is still an @@ -118,37 +170,74 @@ pub fn load() -> Result { load_from(&path) } +/// 当 `config.json` 不存在时使用的内置默认 provider。它们让 focus 对主要目标 +/// (Z.ai / 智谱 GLM Coding Plan)及其它受支持厂商开箱即用。API key 通过 +/// `focus login` 或 `--api-key` 单独提供。 /// The built-in default providers used when no `config.json` exists. These make -/// focus work out of the box for the primary target (Z.ai / 智谱) plus the -/// other supported vendors. API keys are supplied separately via +/// focus work out of the box for the primary target (Z.ai / 智谱 GLM Coding +/// Plan) plus the other supported vendors. API keys are supplied separately via /// `focus login` or `--api-key`. +/// +/// 默认的 `zai-coding-cn` 指向 **GLM Coding Plan** 档位,该档位的 key +/// (`xxxxxxxx.yyyyyyyy` 格式)必须使用 `/api/coding/paas/v4/chat/completions`—— +/// 标准 `/api/paas/v4/...` 路径会对这类 key 返回 `429 余额不足`。 +/// The default `zai-coding-cn` targets the **GLM Coding Plan** tier, whose keys +/// (`xxxxxxxx.yyyyyyyy` format) must use `/api/coding/paas/v4/chat/completions` +/// — the standard `/api/paas/v4/...` path reports `429 余额不足` for them. pub fn builtin_defaults() -> Config { - let mk = |kind: &str, region: &str, model: &str, base_url: Option<&str>| ProviderEntry { - kind: kind.to_string(), - region: region.to_string(), - model: model.to_string(), - api_key: String::new(), - base_url: base_url.map(String::from), + let mk = |kind: &str, region: &str, model: &str, base_url: Option<&str>, path: Option<&str>| { + ProviderEntry { + kind: kind.to_string(), + region: region.to_string(), + model: model.to_string(), + api_key: String::new(), + base_url: base_url.map(String::from), + path: path.map(String::from), + } }; Config { default: "zai-coding-cn".to_string(), providers: vec![ ( "zai-coding-cn".to_string(), - mk("zai", "cn", "glm-5.2", None), + mk( + "zai", + "cn", + "glm-5.2", + None, + Some(focus_providers::ZAI_CODING_PATH), + ), + ), + ( + "zai-coding-intl".to_string(), + mk( + "zai", + "intl", + "glm-5.2", + None, + Some(focus_providers::ZAI_CODING_PATH), + ), + ), + // 标准按量付费 PaaS 档位(与 coding plan 区分开)。 + // Standard pay-as-you-go PaaS tier (separate from the coding plan). + ("zai-cn".to_string(), mk("zai", "cn", "glm-5.2", None, None)), + ( + "zai-intl".to_string(), + mk("zai", "intl", "glm-5.2", None, None), ), - ("zai-intl".to_string(), mk("zai", "intl", "glm-5.2", None)), ( "anthropic".to_string(), - mk("anthropic", "", "claude-sonnet-4-5", None), + mk("anthropic", "", "claude-sonnet-4-5", None, None), ), - ("openai".to_string(), mk("openai", "", "gpt-4o", None)), + ("openai".to_string(), mk("openai", "", "gpt-4o", None, None)), ], } } +/// 从显式路径加载配置。便于测试。 /// Load config from an explicit path. Useful for tests. pub fn load_from(path: &Path) -> Result { + // 配置文件缺失不是错误——返回空配置,让调用者决定如何引导用户。 // A missing config file is not an error — return an empty config so the // caller can decide how to guide the user. if !path.exists() { @@ -160,6 +249,8 @@ pub fn load_from(path: &Path) -> Result { parse_config(&value) } +/// 将解析后的 JSON 值转换为 [`Config`]。 +/// Convert a parsed JSON value into a [`Config`]. fn parse_config(value: &JsonValue) -> Result { let default = value .get_str("default") @@ -183,6 +274,8 @@ fn parse_config(value: &JsonValue) -> Result { .get_str("model") .ok_or_else(|| ConfigError::Missing(format!("provider '{}' missing 'model'", name)))? .to_string(); + // apiKey 在 config.json 中是可选的——key 通常通过 `focus login`(auth.json) + // 或 --api-key 提供。若缺失则默认为空,后续解析会失败并给出友好提示。 // apiKey is optional in config.json — the key is usually supplied via // `focus login` (auth.json) or --api-key. If absent, it defaults to // empty and resolution fails later with a helpful message. @@ -193,6 +286,7 @@ fn parse_config(value: &JsonValue) -> Result { focus_providers::resolve_config_value(raw_key).map_err(ConfigError::Io)? }; let base_url = entry.get_str("baseUrl").map(String::from); + let path = entry.get_str("path").map(String::from); providers.push(( name.clone(), ProviderEntry { @@ -201,6 +295,7 @@ fn parse_config(value: &JsonValue) -> Result { model, api_key, base_url, + path, }, )); } @@ -251,6 +346,7 @@ mod tests { #[test] fn provider_without_apikey_is_accepted() { + // apiKey 是可选的——key 通过 `focus login` 或 --api-key 提供。 // apiKey is optional — the key is supplied via `focus login` or --api-key. let text = r#"{ "default": "zai-coding-cn", @@ -286,6 +382,7 @@ mod tests { #[test] fn missing_file_returns_empty_config() { + // 不存在的路径不是错误——返回空配置,以便 CLI 打印安装引导而不是崩溃。 // A nonexistent path is NOT an error — returns an empty config so the // CLI can print setup guidance instead of crashing. let path = Path::new("/nonexistent/focus/config-test-does-not-exist.json"); @@ -297,6 +394,7 @@ mod tests { #[test] fn malformed_file_is_an_error() { + // 文件存在但 JSON 无效才是真正的错误。 // A file that exists but has invalid JSON is a real error. let dir = std::env::temp_dir(); let path = dir.join("focus-malformed-test.json"); diff --git a/crates/focus-cli/src/login.rs b/crates/focus-cli/src/login.rs index d7bed89..4e3fdec 100644 --- a/crates/focus-cli/src/login.rs +++ b/crates/focus-cli/src/login.rs @@ -1,5 +1,9 @@ +//! `/login` 与 `/logout` 流程,供 REPL 调用。 //! `/login` and `/logout` flows, used from the REPL. //! +//! 镜像 pi 的做法:提示用户选择 provider(或接受命令行传入的 provider), +//! 读取一个 API key,并原样持久化到 `~/.focus/auth.json`。登录时不做任何 +//! 验证 API 调用。 //! Mirrors pi's approach: prompt the user to pick a provider (or accept one //! passed on the command line), read an API key, and persist it to //! `~/.focus/auth.json` verbatim. No validation API call is made at login time. @@ -8,13 +12,24 @@ use crate::auth::{AuthError, AuthStore, Credential}; use crate::config::Config; use std::io::{BufRead, Write}; +/// 登录流程的错误。 /// Errors from the login flow. #[derive(Debug)] pub enum LoginError { + /// 来自凭据存储的错误。 + /// An error from the auth store. Auth(AuthError), + /// I/O 错误。 + /// An I/O error. Io(String), + /// 用户取消(如空输入或 EOF)。 + /// The user cancelled (e.g. empty input or EOF). Cancelled, + /// 未配置任何 provider。 + /// No providers are configured. NoProviders, + /// 指定的 provider 未知。 + /// The given provider is unknown. UnknownProvider(String), } @@ -38,14 +53,19 @@ impl From for LoginError { } } +/// 一次登录操作的参数。 /// Parameters for a login operation. pub struct LoginParams { + /// 显式 provider id。若为 `None`,则提示用户选择。 /// Explicit provider id. If `None`, prompt the user to choose. pub provider: Option, + /// 显式 key。若为 `None`,则提示用户输入。 /// Explicit key. If `None`, prompt the user. pub key: Option, } +/// 执行登录流程。`reader` 是 REPL 持有的 stdin 行读取器。 +/// 返回已登录的 provider id。 /// Run the login flow. `reader` is the stdin line reader owned by the REPL. /// Returns the provider id that was logged in. pub fn run_login( @@ -54,6 +74,7 @@ pub fn run_login( params: LoginParams, reader: &mut R, ) -> Result { + // 1. 解析 provider。 // 1. Resolve the provider. let provider = match params.provider { Some(p) => p, @@ -65,6 +86,7 @@ pub fn run_login( } }; + // 2. 解析 key。 // 2. Resolve the key. let key = match params.key { Some(k) => k, @@ -74,6 +96,7 @@ pub fn run_login( return Err(LoginError::Cancelled); } + // 3. 持久化。原样存储(调用者可传入字面量或 $VAR)。 // 3. Persist. Store verbatim (caller may pass a literal or $VAR). store.modify( &provider, @@ -83,11 +106,13 @@ pub fn run_login( }, )?; + // 4. 确认。 // 4. Confirm. eprintln!("Credentials saved to {}", store.path().display()); Ok(provider) } +/// 执行登出流程:删除某 provider 的凭据。 /// Run the logout flow: remove a provider's credential. pub fn run_logout( store: &AuthStore, @@ -126,8 +151,10 @@ pub fn run_logout( Ok(provider) } +// ---- 交互式辅助函数(全部经由调用方的 reader 读取)------------ // ---- interactive helpers (all read via the caller's reader) --------------- +/// 从 `reader` 读取一行并 trim。遇到 EOF 时返回 `Cancelled`。 /// Read one trimmed line from `reader`. Returns `Cancelled` on EOF. fn read_line(reader: &mut R) -> Result { let mut line = String::new(); @@ -140,6 +167,8 @@ fn read_line(reader: &mut R) -> Result { Ok(line.trim_end_matches(['\n', '\r']).to_string()) } +/// 提示用户从配置的 provider 中选择一个;仅一个时直接返回。 +/// Prompt the user to pick a provider from the config; returns the sole one directly. fn prompt_provider(cfg: &Config, reader: &mut R) -> Result { let names: Vec<&String> = cfg.providers.iter().map(|(n, _)| n).collect(); if names.len() == 1 { @@ -149,6 +178,8 @@ fn prompt_provider(cfg: &Config, reader: &mut R) -> Result( options: &[&str], prompt: &str, @@ -172,9 +203,13 @@ fn prompt_from_list( Err(LoginError::Cancelled) } +/// 提示输入某 provider 的 API key;从调用方的 reader 读取。 +/// Prompt for an API key for the given provider; reads from the caller's reader. fn prompt_key(provider: &str, reader: &mut R) -> Result { eprint!("Enter API key for {}: ", provider); let _ = std::io::stderr().flush(); + // 从调用方的 reader(REPL 的 stdin 句柄)读取。我们不关闭终端回显 + // (那需要 termios);输入 key 时字符可见。 // Read from the caller's reader (the REPL's stdin handle). We don't disable // terminal echo (would require termios); the key is visible while typing. read_line(reader) @@ -212,6 +247,7 @@ mod tests { model: "glm-4.6".into(), api_key: String::new(), base_url: None, + path: None, }, )], } @@ -269,9 +305,10 @@ mod tests { ProviderEntry { kind: "zai".into(), region: "cn".into(), - model: "glm-4.6".into(), + model: "glm-5.2".into(), api_key: String::new(), base_url: None, + path: None, }, ), ( @@ -282,10 +319,12 @@ mod tests { model: "claude".into(), api_key: String::new(), base_url: None, + path: None, }, ), ], }; + // 输入:选择选项 [2](anthropic),再输入一个 key。 // Input: pick option [2] (anthropic), then enter a key. let mut reader = Cursor::new(b"2\nanthro-key\n".to_vec()); let provider = run_login( diff --git a/crates/focus-cli/src/main.rs b/crates/focus-cli/src/main.rs index 9691fe3..0ddefc0 100644 --- a/crates/focus-cli/src/main.rs +++ b/crates/focus-cli/src/main.rs @@ -7,6 +7,8 @@ mod runner; fn main() { let cli = cli::parse(); + // 构建一个多线程 tokio 运行时。核心循环是同步的(它从 provider 拉取事件), + // 但 provider 内部会为 HTTP 请求创建运行时,因此这里需要先备好一个。 // Build a multi-threaded tokio runtime. The core loop is synchronous (it // pulls events from the provider), but providers create a runtime // internally for the HTTP request, so we need one available. @@ -21,33 +23,41 @@ fn main() { } } +/// 在 REPL 模式(无 prompt)与单次 prompt 模式(有 prompt)之间分派。 /// Decide between REPL mode (no prompt) and single-prompt mode (prompt given). fn dispatch(cli: cli::Cli) -> Result<(), Box> { if cli.help { println!("{}", cli::Cli::usage()); return Ok(()); } + // 无 prompt 且 stdin 是 TTY → 进入交互式 REPL。 + // 无 prompt 但 stdin 是管道输入 → 从 stdin 读取 prompt(单次运行)。 // No prompt and stdin is a TTY → enter the interactive REPL. // No prompt but stdin is piped → read the prompt from stdin (single-run). let has_prompt = cli.prompt.is_some(); let stdin_is_tty = is_stdin_tty(); if !has_prompt && stdin_is_tty { + // 交互式 REPL。 // Interactive REPL. repl::run(cli.api_key, cli.provider) .map_err(|e| -> Box { e.to_string().into() })?; return Ok(()); } + // 单次 prompt 模式。 // Single-prompt mode. runner::run(cli).map_err(|e| -> Box { e.to_string().into() })?; Ok(()) } +/// 当 stdin 连接到终端(而非管道/重定向)时为真。 /// True when stdin is connected to a terminal (not piped/redirected). fn is_stdin_tty() -> bool { #[cfg(unix)] { + // 通过 std::os 使用 libc 的 isatty,避免引入 libc 这个 crate。 + // std::io::IsTerminal 自 1.70 起稳定可用。 // Use a libc isatty via std::os without pulling in the libc crate. // std::io::IsTerminal is stable since 1.70. use std::io::IsTerminal; diff --git a/crates/focus-cli/src/repl.rs b/crates/focus-cli/src/repl.rs index 7a70134..c525c63 100644 --- a/crates/focus-cli/src/repl.rs +++ b/crates/focus-cli/src/repl.rs @@ -1,17 +1,29 @@ +//! 交互式 REPL。 //! Interactive REPL. //! +//! 当 focus 不带 prompt 参数运行时进入。从 stdin 逐行读取:以 `/` 开头的 +//! 行是斜杠命令,其余内容作为 prompt 发送给模型,构成持续的多轮对话。 //! Entered when focus is run with no prompt argument. Reads lines from stdin: //! `/`-prefixed lines are slash commands, everything else is a prompt sent to //! the model in an ongoing multi-turn conversation. //! +//! 支持的斜杠命令: //! Supported slash commands: +//! - `/login [provider]` — 将 API key 存入 auth.json //! - `/login [provider]` — store an API key in auth.json +//! - `/logout [provider]` — 删除已存储的凭据 //! - `/logout [provider]` — remove a stored credential +//! - `/model [name]` — 显示或切换当前模型 //! - `/model [name]` — show or switch the active model +//! - `/system [text]` — 显示或设置系统提示词 //! - `/system [text]` — show or set the system prompt +//! - `/clear` — 清空对话记录 //! - `/clear` — clear the conversation transcript +//! - `/config` — 打印解析后的配置 //! - `/config` — print the resolved config +//! - `/help` — 列出命令 //! - `/help` — list commands +//! - `/quit`(或 `/exit`)— 退出 REPL //! - `/quit` (or `/exit`) — leave the REPL use crate::auth::AuthStore; @@ -25,12 +37,21 @@ use focus_core::{Agent, AgentConfig}; use std::io::{self, BufRead, Write}; // BufRead is used for the stdin handle passed into slash-command handlers. +/// REPL 的错误。 /// Errors from the REPL. #[derive(Debug)] pub enum ReplError { + /// 配置错误。 + /// A config error. Config(config::ConfigError), + /// 凭据存储错误。 + /// An auth-store error. Auth(crate::auth::AuthError), + /// Agent 执行错误。 + /// An agent execution error. Agent(String), + /// 登录流程错误。 + /// A login-flow error. Login(login::LoginError), } @@ -65,25 +86,34 @@ impl From for ReplError { } } +/// 可变的 REPL 会话状态。 /// Mutable REPL session state. struct ReplState { + /// 当前生效的配置(加载一次,可用 /config 重载)。 /// The active config (loaded once, may be reloaded with /config). cfg: Config, + /// 凭据存储。 /// The auth store. store: AuthStore, + /// 当前 provider 名称。 /// Active provider name. provider: String, + /// 当前模型(可通过 /model 覆盖)。 /// Active model (overridable via /model). model: String, + /// 系统提示词(可通过 /system 覆盖)。 /// System prompt (overridable via /system). system: String, + /// 内存中的 API key 覆盖值(来自 `--api-key`),若有。 /// In-memory API key override (from `--api-key`), if any. api_key_override: Option, + /// 运行中的 agent(当 provider/model/system 变化时重建)。 /// The running agent (rebuilt when provider/model/system change). agent: Option, } impl ReplState { + /// 为当前 provider/model/key 构建 StreamProvider。 /// Build the StreamProvider for the current provider/model/key. fn build_provider(&self) -> Result, ReplError> { let entry = self.cfg.provider(&self.provider).ok_or_else(|| { @@ -95,6 +125,7 @@ impl ReplState { .map_err(|e| ReplError::Agent(e.to_string())) } + /// 按优先级解析 API key:override > auth.json > config。 /// Resolve the API key with precedence: override > auth.json > config. fn resolve_api_key(&self, entry: &crate::config::ProviderEntry) -> Result { if let Some(k) = &self.api_key_override { @@ -112,6 +143,7 @@ impl ReplState { ))) } + /// 懒构建(或重建)agent。 /// Lazily build (or rebuild) the agent. fn ensure_agent(&mut self) -> Result<&mut Agent, ReplError> { if self.agent.is_none() { @@ -126,11 +158,13 @@ impl ReplState { Ok(self.agent.as_mut().unwrap()) } + /// 强制下次 ensure_agent 时重建(在 provider/model 变化后调用)。 /// Force a rebuild on the next ensure_agent (after provider/model changes). fn invalidate_agent(&mut self) { self.agent = None; } + /// 将 prompt 发送给 agent,并打印流式响应。 /// Send a prompt through the agent and print the streamed response. fn send_prompt(&mut self, text: &str) -> Result<(), ReplError> { let agent = self.ensure_agent()?; @@ -142,10 +176,14 @@ impl ReplState { } } +/// 一个 sink:将流式到达的 assistant 增量(delta)按到达顺序打印到 stdout, +/// 通过记录当前消息已打印的字符数避免重复输出。 /// A sink that prints streamed assistant deltas to stdout as they arrive, /// avoiding duplication by tracking how many chars of the current message have /// already been printed. struct ReplSink { + /// 当前消息已打印到 stdout 的字符数(用于增量输出)。 + /// Number of chars of the current message already printed to stdout (for delta output). printed: usize, } @@ -188,6 +226,7 @@ impl EventSink for ReplSink { } } +/// 拼接 assistant 消息中的所有文本块。 /// Concatenate all text blocks of an assistant message. fn assistant_text(a: &focus_core::model::AssistantMessage) -> String { a.content @@ -200,6 +239,7 @@ fn assistant_text(a: &focus_core::model::AssistantMessage) -> String { .join("") } +/// 运行 REPL。`api_key_override` 是内存中的 `--api-key`(若提供)。 /// Run the REPL. `api_key_override` is the in-memory `--api-key`, if provided. pub fn run( api_key_override: Option, @@ -225,6 +265,8 @@ pub fn run( }; let stdin = io::stdin(); + // 为整个 REPL 会话只锁一次 stdin。锁定的句柄会传入斜杠命令处理器,使其能 + // 读取额外行(例如 /login 的 API key)而无需重新加锁导致死锁。 // Lock stdin once for the whole REPL session. The locked handle is passed // into slash-command handlers so they can read additional lines (e.g. the // API key for /login) without re-locking and deadlocking. @@ -243,7 +285,7 @@ pub fn run( } }; if n == 0 { - break; // EOF (Ctrl-D) + break; // EOF(Ctrl-D)/ EOF (Ctrl-D) } let trimmed = line.trim().to_string(); if trimmed.is_empty() { @@ -265,12 +307,19 @@ pub fn run( Ok(()) } +/// 斜杠命令的执行结果。 /// Outcome of a slash command. enum CommandResult { + /// 继续读取下一行。 + /// Keep reading the next line. Continue, + /// 退出 REPL。 + /// Quit the REPL. Quit, } +/// 分派一个 `/command`。`reader` 是共享的 stdin 句柄,以便处理器读取额外 +/// 输入行(例如 /login 的 API key)。 /// Dispatch a `/command`. `reader` is the shared stdin handle so handlers can /// read additional input lines (e.g. the API key for /login). fn handle_command( @@ -278,6 +327,7 @@ fn handle_command( state: &mut ReplState, reader: &mut R, ) -> Result { + // 拆分为 command + args。 // Split into command + args. let mut parts = input.splitn(2, char::is_whitespace); let cmd = parts.next().unwrap_or(""); @@ -343,6 +393,7 @@ fn handle_command( println!("active provider: {}", state.provider); } else if state.cfg.provider(arg).is_some() { state.provider = arg.to_string(); + // 将 model 更新为该 provider 的默认值。 // Update model to the provider's default. if let Some(e) = state.cfg.provider(&state.provider) { state.model = e.model.clone(); diff --git a/crates/focus-cli/src/runner.rs b/crates/focus-cli/src/runner.rs index e9f23a6..c779c98 100644 --- a/crates/focus-cli/src/runner.rs +++ b/crates/focus-cli/src/runner.rs @@ -1,8 +1,13 @@ +//! 执行一次 prompt,或分派 `login`/`logout` 子命令。 //! Run a prompt, or dispatch `login`/`logout` subcommands. //! +//! 凭据解析顺序(按优先级从高到低,镜像 pi): //! Credential resolution order (highest precedence first), mirroring pi: +//! 1. `--api-key` CLI 覆盖(仅内存,从不持久化)。 //! 1. `--api-key` CLI override (in-memory only, never persisted). +//! 2. 该 provider 在 `auth.json` 中的条目(由 `focus login` 写入)。 //! 2. `auth.json` entry for the provider (written by `focus login`). +//! 3. `config.json` 中 provider 的 `apiKey`(加载时已解析)。 //! 3. `config.json` provider `apiKey` (already resolved at load time). use crate::auth::AuthStore; @@ -15,12 +20,21 @@ use focus_core::{Agent, AgentConfig}; use focus_providers::{AnthropicProvider, OpenAiCompatProvider, ProviderConfig, ZaiProvider}; use std::io::{self, Read, Write}; +/// 运行期间产生的错误。 /// Errors produced while running. #[derive(Debug)] pub enum RunError { + /// 配置错误。 + /// A config error. Config(config::ConfigError), + /// 凭据存储错误。 + /// An auth-store error. Auth(crate::auth::AuthError), + /// provider 构建错误。 + /// A provider-construction error. Provider(String), + /// Agent 执行错误。 + /// An agent execution error. Agent(String), } @@ -49,6 +63,7 @@ impl From for RunError { } } +/// 根据 CLI 参数驱动一次完整运行(单次 prompt 模式)。 /// Drive one full run based on CLI args (the single-prompt mode). pub fn run(cli: Cli) -> Result<(), RunError> { if cli.help { @@ -67,6 +82,7 @@ pub fn run(cli: Cli) -> Result<(), RunError> { return Ok(()); } + // 当未配置任何 provider 且未提供 --api-key 覆盖时,打印安装引导并干净退出。 // If no providers are configured AND no --api-key override was given, // print setup guidance and exit cleanly. if cfg.is_empty() && cli.api_key.is_none() { @@ -74,6 +90,8 @@ pub fn run(cli: Cli) -> Result<(), RunError> { return Ok(()); } + // 解析 provider 条目。有 --api-key 时即便配置为空也能运行(合成一个 zai 条目), + // 但仍然需要一个 provider 名称。 // Resolve the provider entry. With --api-key we can run even with an empty // config (synthesizing a zai entry), but a provider name is still needed. let provider_name = cli.provider.clone().unwrap_or_else(|| cfg.default.clone()); @@ -95,6 +113,7 @@ pub fn run(cli: Cli) -> Result<(), RunError> { return Err(RunError::Agent("empty prompt".into())); } + // 按本文件顶部记录的优先级解析 API key。 // Resolve the API key with the precedence documented at the top of this // file. let store = AuthStore::open()?; @@ -121,6 +140,11 @@ pub fn run(cli: Cli) -> Result<(), RunError> { Ok(()) } +/// 解析最终的 API key。优先级: +/// 1. `--api-key` 覆盖(仅内存)。 +/// 2. `auth.json` 条目。 +/// 3. `config.json` 的 `apiKey`(加载时已解析)。 +/// /// Resolve the final API key. Precedence: /// 1. `--api-key` override (in-memory). /// 2. `auth.json` entry. @@ -146,6 +170,7 @@ fn resolve_api_key( ))) } +/// 从配置条目构建一个 [`StreamProvider`]。单次 prompt runner 与 REPL 共用。 /// Build a [`StreamProvider`] from a config entry. Shared by the single-prompt /// runner and the REPL. pub fn build_provider_pub( @@ -156,6 +181,8 @@ pub fn build_provider_pub( build_provider(entry, model_override, api_key) } +/// 根据 provider `kind` 构造具体的 StreamProvider 实例(内部函数)。 +/// Construct the concrete StreamProvider instance by provider `kind` (internal). fn build_provider( entry: &ProviderEntry, model_override: Option<&str>, @@ -172,7 +199,15 @@ fn build_provider( if let Some(host) = &entry.base_url { pc.base_url = host.clone(); } - Ok(Box::new(ZaiProvider::from_config(pc))) + // Coding-plan 的 key 使用专用路径(/api/coding/paas/v4/...); + // 否则回退到标准 PaaS 路径(/api/paas/v4/...)。 + // Coding-plan keys use a distinct path (/api/coding/paas/v4/...); + // fall back to the standard PaaS path (/api/paas/v4/...). + let provider = match entry.path.as_deref() { + Some(path) => ZaiProvider::from_config_with_path(pc, path), + None => ZaiProvider::from_config(pc), + }; + Ok(Box::new(provider)) } "openai" => { let host = entry @@ -193,6 +228,8 @@ fn build_provider( } } +/// 将 stdin 的剩余内容读取为一个 prompt。 +/// Read the rest of stdin as a prompt. fn read_stdin() -> Result { let mut buf = String::new(); io::stdin() @@ -201,6 +238,8 @@ fn read_stdin() -> Result { Ok(buf) } +/// 将解析后的配置打印到 stdout。 +/// Print the resolved config to stdout. fn print_config(cfg: &Config) { println!("default provider: {}", cfg.default); for (name, entry) in &cfg.providers { @@ -211,6 +250,7 @@ fn print_config(cfg: &Config) { } } +/// 为尚无配置文件的首次用户打印引导信息。 /// Print guidance for first-run users who have no config file yet. fn print_no_config_hint() { let dir = config::config_dir() @@ -244,6 +284,7 @@ fn print_no_config_hint() { eprintln!(" focus \"你好\""); } +/// 一个事件 sink:将最终的 assistant 文本打印到 stdout。 /// An event sink that prints the final assistant text to stdout. struct StdoutSink; @@ -261,6 +302,7 @@ impl EventSink for StdoutSink { } } +/// 提取 assistant 消息的拼接文本。 /// Extract the concatenated text of an assistant message. fn assistant_text(a: &focus_core::model::AssistantMessage) -> Option { let mut out = String::new(); diff --git a/crates/focus-core/src/agent.rs b/crates/focus-core/src/agent.rs index 1500c4d..aa1fa9b 100644 --- a/crates/focus-core/src/agent.rs +++ b/crates/focus-core/src/agent.rs @@ -1,9 +1,13 @@ +//! agent 循环——框架的核心。 //! The agent loop — the heart of the framework. //! +//! 实现 pi 的双层循环设计:内层循环排空 steering 消息并执行工具调用, +//! 外层循环在 agent 本应停止之后继续处理 follow-up 消息。 //! Implements pi's double-loop design: an inner loop that drains steering //! messages and executes tool calls, and an outer loop that picks up follow-up //! messages after the agent would otherwise stop. //! +//! 带注释的控制流见 `docs/architecture/02-agent-loop.md`。 //! See `docs/architecture/02-agent-loop.md` for the annotated control flow. use crate::error::{CoreError, CoreResult}; @@ -12,13 +16,23 @@ use crate::model::*; use crate::provider::{ProviderRequest, StreamEvent, StreamProvider}; use crate::tool::{ToolEffects, ToolRegistry, ToolResult}; +/// 一次 [`Agent`] 运行的配置。 /// Configuration for an [`Agent`] run. #[derive(Clone)] pub struct AgentConfig { + /// 模型 id。 + /// The model id. pub model: String, + /// 系统提示词。 + /// The system prompt. pub system_prompt: String, + /// 最大输出 token 数。 + /// Max output tokens. pub max_tokens: Option, + /// 采样温度。 + /// Sampling temperature. pub temperature: Option, + /// 强制停止前允许的最大工具调用回合数。 /// Maximum number of tool-call turns before forcing a stop. pub max_tool_iterations: Option, } @@ -35,6 +49,7 @@ impl Default for AgentConfig { } } +/// agent 持有对话记录、工具注册表与一个 provider。它是驱动对话的唯一入口。 /// The agent owns the transcript, the tool registry, and a provider. It is the /// single entry point for driving a conversation. pub struct Agent { @@ -45,6 +60,8 @@ pub struct Agent { } impl Agent { + /// 用配置、provider 与工具注册表构造 agent。 + /// Build an agent from config, a provider, and a tool registry. pub fn new( config: AgentConfig, provider: Box, @@ -58,14 +75,19 @@ impl Agent { } } + /// 只读访问对话记录。 + /// Read-only access to the transcript. pub fn messages(&self) -> &[Message] { &self.messages } + /// 只读访问 agent 配置。 + /// Read-only access to the agent config. pub fn config(&self) -> &AgentConfig { &self.config } + /// 发送一条用户 prompt 并运行 agent 循环至完成。 /// Send a user prompt and run the agent loop to completion. pub fn prompt(&mut self, text: impl Into, sink: &mut dyn EventSink) -> CoreResult<()> { let user = Message::user_text(text); @@ -80,6 +102,7 @@ impl Agent { Ok(()) } + /// 继续对话(最后一条消息不能是 assistant)。 /// Continue the conversation (last message must not be assistant). pub fn r#continue(&mut self, sink: &mut dyn EventSink) -> CoreResult<()> { if self @@ -98,13 +121,17 @@ impl Agent { Ok(()) } + /// 双层循环。 /// The double loop. fn run_loop(&mut self, sink: &mut dyn EventSink) -> CoreResult<()> { let mut new_messages: Vec = Vec::new(); let mut has_more_tool_calls = true; let mut iteration = 0usize; + // 外层循环:只要还有工具调用就继续推进(steering + follow-up)。 + // Outer loop: keep going as long as there are tool calls (steering + follow-up). while has_more_tool_calls { + // 流式接收一次 assistant 响应。 // Stream one assistant response. let message = self.stream_assistant_response(sink)?; new_messages.push(Message::Assistant(message.clone())); @@ -120,6 +147,7 @@ impl Agent { return Ok(()); } + // 检查回合预算。 // Check budget. if let Some(max) = self.config.max_tool_iterations { if iteration >= max { @@ -128,6 +156,7 @@ impl Agent { } iteration += 1; + // 提取并执行工具调用。 // Extract and execute tool calls. let tool_calls: Vec = message .content @@ -140,6 +169,7 @@ impl Agent { if !tool_calls.is_empty() { if message.stop_reason == StopReason::Length { + // 输出被截断:参数可能不完整。全部判失败。 // Truncated output: arguments may be incomplete. Fail all. for tc in &tool_calls { let result = ToolResult::error(format!( @@ -182,6 +212,7 @@ impl Agent { Ok(()) } + /// 从当前状态构造 [`ProviderRequest`] 并流式接收响应。 /// Build a [`ProviderRequest`] from current state and stream a response. fn stream_assistant_response( &mut self, @@ -203,6 +234,8 @@ impl Agent { match &event { StreamEvent::Start { partial: p } => { partial = Some(p.clone()); + // 立即把流式中的 assistant 消息推入记录, + // 让对话记录始终反映流式状态。 // Push the live assistant message immediately so the // transcript always reflects the streaming state. self.messages.push(Message::Assistant(p.clone())); @@ -227,6 +260,7 @@ impl Agent { } StreamEvent::Done { message } => { let final_msg = if partial.is_some() { + // 用最终消息替换流式中的 partial。 // Replace the live partial with the final message. if let Some(Message::Assistant(old)) = self.messages.last_mut() { *old = message.clone(); @@ -265,6 +299,7 @@ impl Agent { } } + // 流在没有终止事件的情况下结束:用最后一个 partial 合成一条。 // Stream ended without a terminal event: synthesize one from the last partial. let synthesized = partial.unwrap_or_else(|| AssistantMessage { content: vec![], @@ -285,6 +320,7 @@ impl Agent { Ok(synthesized) } + /// 执行一批工具调用,把兼容的调用分组以并行执行。 /// Execute a batch of tool calls, grouping compatible ones for parallelism. fn execute_tool_calls( &self, @@ -293,6 +329,8 @@ impl Agent { ) -> CoreResult { let mut finalized: Vec<(ToolCall, ToolResult, bool)> = Vec::new(); + // 收集每个工具调用的结果(错误也编码为结果)。 + // Collect each tool call's result (errors encoded as results too). for tc in tool_calls { sink.emit(AgentEvent::ToolExecutionStart { tool_call_id: tc.id.clone(), @@ -309,6 +347,8 @@ impl Agent { finalized.push((tc.clone(), result.0, result.1)); } + // terminate 须满足"法定人数":非空且所有结果都请求终止。 + // terminate requires a quorum: non-empty and every result requests it. let terminate = !finalized.is_empty() && finalized.iter().all(|(_, r, _)| r.terminate); let messages = finalized .iter() @@ -325,6 +365,7 @@ impl Agent { }) } + /// 查找工具、校验并运行。错误会被编码为结果。 /// Look up the tool, validate, and run it. Errors are encoded as results. fn execute_single(&self, tc: &ToolCall, sink: &mut dyn EventSink) -> CoreResult { let tool = self @@ -352,14 +393,23 @@ impl std::fmt::Debug for Agent { } } +/// 一次运行返回的句柄(目前仅作信息用途)。 /// A handle returned from a run (currently just informational). pub struct AgentHandle; +/// 一批已执行工具调用的结果。 +/// The results of one executed batch of tool calls. struct ExecutedBatch { + /// 产生的 tool-result 消息。 + /// Produced tool-result messages. messages: Vec, + /// 本批次是否请求终止。 + /// Whether this batch requested termination. terminate: bool, } +/// 把 [`ToolResult`] 整理成一条 [`ToolResultMessage`]。 +/// Finalize a [`ToolResult`] into a [`ToolResultMessage`]. fn finalize_tool_result(tc: &ToolCall, result: ToolResult, is_error: bool) -> ToolResultMessage { ToolResultMessage { tool_call_id: tc.id.clone(), @@ -375,6 +425,8 @@ fn finalize_tool_result(tc: &ToolCall, result: ToolResult, is_error: bool) -> To } } +/// 向 sink 发出一条 tool-result 消息的 Start/End 事件。 +/// Emit the Start/End events for a tool-result message. fn emit_tool_result_message(trm: &ToolResultMessage, sink: &mut dyn EventSink) { sink.emit(AgentEvent::MessageStart { message: Message::ToolResult(trm.clone()), @@ -384,13 +436,19 @@ fn emit_tool_result_message(trm: &ToolResultMessage, sink: &mut dyn EventSink) { }); } +/// 根据各工具调用声明的副作用,规划并行安全的批次。 +/// (设为 pub 以便 provider/测试可以调用。) /// Plan parallel-safe batches from a set of tool calls by their declared effects. /// (Public so providers/tests can exercise it.) pub fn plan_batches(calls: &[(ToolCall, ToolEffects)]) -> Vec> { let mut batches: Vec> = Vec::new(); + // 贪心装箱:把每个调用放进第一个与之兼容的批次,否则开新批次。 + // Greedy bucketing: place each call into the first compatible batch, else open a new one. for (i, (_, effects)) in calls.iter().enumerate() { let mut placed = false; for batch in &mut batches { + // 双向兼容才可同批。 + // Both directions must be compatible to share a batch. let compatible = batch.iter().all(|&j| { calls[j].1.compatible_with(*effects) && effects.compatible_with(calls[j].1) }); @@ -435,6 +493,7 @@ mod batch_tests { (tc("c"), ToolEffects::READ), ]; let batches = plan_batches(&calls); + // a、c 一起读,b 单独写。 // a+c read together, b write alone assert_eq!(batches.len(), 2); assert!(batches.iter().any(|b| b == &vec![0, 2])); diff --git a/crates/focus-core/src/error.rs b/crates/focus-core/src/error.rs index ba5cb2f..44245b2 100644 --- a/crates/focus-core/src/error.rs +++ b/crates/focus-core/src/error.rs @@ -1,22 +1,31 @@ +//! core agent 层的错误类型。 //! Error types for the core agent layer. use std::fmt; +/// core agent 层抛出的错误。 /// Errors raised by the core agent layer. #[derive(Debug)] pub enum CoreError { + /// 工具返回错误或发生 panic。 /// A tool returned an error or panicked. Tool(String), + /// agent 找不到请求的工具。 /// The agent could not find a requested tool. ToolNotFound(String), + /// 工具参数未通过类 JSON-schema 校验。 /// Tool arguments failed JSON-schema-like validation. InvalidArgs(String), + /// 流式 provider 产生了非法的事件序列。 /// The stream provider produced a malformed event sequence. Provider(String), + /// 领域类型的序列化/反序列化失败。 /// Serialization/deserialization of a domain type failed. Json(String), + /// 操作被取消。 /// An operation was cancelled. Cancelled, + /// 配置不变量被违反。 /// A configuration invariant was violated. Config(String), } @@ -37,11 +46,14 @@ impl fmt::Display for CoreError { impl std::error::Error for CoreError {} +/// 将 JSON 错误转换为 core 错误(归入 Json 变体)。 +/// Convert a JSON error into a core error (as the Json variant). impl From for CoreError { fn from(e: focus_json::JsonError) -> Self { CoreError::Json(e.to_string()) } } +/// 便捷别名。 /// Convenience alias. pub type CoreResult = Result; diff --git a/crates/focus-core/src/event.rs b/crates/focus-core/src/event.rs index 8287484..4da2f0d 100644 --- a/crates/focus-core/src/event.rs +++ b/crates/focus-core/src/event.rs @@ -1,43 +1,55 @@ +//! Agent 事件协议——[`Agent`](crate::agent::Agent) 向 UI 消费者发出的事件。 //! Agent event protocol — what the [`Agent`](crate::agent::Agent) emits to UI //! consumers. use crate::model::*; +/// agent 生命周期中的一个事件。 /// One event in the agent's lifecycle. #[derive(Debug, Clone)] pub enum AgentEvent { + /// 运行已开始。 /// The run has started. AgentStart, + /// 运行结束;`messages` 为本次产生的新消息。 /// The run finished; `messages` are the new messages produced. AgentEnd { messages: Vec }, + /// 一个回合(一次 assistant 响应 + 其工具调用/结果)开始。 /// A turn (one assistant response + its tool calls/results) started. TurnStart, + /// 一个回合结束。 /// A turn finished. TurnEnd { message: Message, tool_results: Vec, }, + /// 一条消息进入对话记录。 /// A message entered the transcript. MessageStart { message: Message }, + /// 流式过程中一条 assistant 消息被更新。 /// An assistant message was updated while streaming. MessageUpdate { message: Message, event_type: String, }, + /// 一条消息被最终确定。 /// A message was finalized. MessageEnd { message: Message }, + /// 一个工具开始执行。 /// A tool started executing. ToolExecutionStart { tool_call_id: String, tool_name: String, args: focus_json::JsonValue, }, + /// 一个工具发出增量更新。 /// A tool emitted an incremental update. ToolExecutionUpdate { tool_call_id: String, tool_name: String, partial_result: crate::tool::ToolUpdate, }, + /// 一个工具执行结束。 /// A tool finished. ToolExecutionEnd { tool_call_id: String, @@ -47,14 +59,20 @@ pub enum AgentEvent { }, } +/// [`AgentEvent`] 的接收端。 /// A sink for [`AgentEvent`]s. pub trait EventSink: Send { + /// 接收一个事件。 + /// Receive one event. fn emit(&mut self, event: AgentEvent); } +/// 把事件收集到 `Vec` 的 sink。主要用于测试。 /// A sink that collects events into a `Vec`. Useful for tests. #[derive(Default)] pub struct VecSink { + /// 收集到的事件列表。 + /// The collected events. pub events: Vec, } @@ -69,6 +87,7 @@ impl VecSink { Self::default() } + /// 按变体的字符串标签统计事件数量。 /// Count events of a given variant by its string label. pub fn count(&self, label: &str) -> usize { self.events diff --git a/crates/focus-core/src/json.rs b/crates/focus-core/src/json.rs index 13f0104..1000868 100644 --- a/crates/focus-core/src/json.rs +++ b/crates/focus-core/src/json.rs @@ -1,5 +1,8 @@ +//! 领域类型与 [`JsonValue`] 之间的手写转换。 //! Hand-written conversions between domain types and [`JsonValue`]. //! +//! 我们刻意不使用 serde。每个领域类型都实现 [`FromJson`]/[`ToJson`], +//! 让转换显式且可调试。 //! We deliberately avoid serde. Each domain type implements //! [`FromJson`]/[`ToJson`] so the conversions are explicit and debuggable. @@ -7,16 +10,19 @@ use crate::error::{CoreError, CoreResult}; use crate::model::*; use focus_json::JsonValue; +/// 从 [`JsonValue`] 反序列化一个领域类型。 /// Deserialize a domain type from a [`JsonValue`]. pub trait FromJson: Sized { fn from_json(value: &JsonValue) -> CoreResult; } +/// 把一个领域类型序列化为 [`JsonValue`]。 /// Serialize a domain type into a [`JsonValue`]. pub trait ToJson { fn to_json(&self) -> JsonValue; } +// ---- 内容块 ---- // ---- content blocks ------------------------------------------------------- impl FromJson for ContentBlock { @@ -120,6 +126,7 @@ impl ToJson for ToolCall { } } +// ---- 用量 ---- // ---- usage ---------------------------------------------------------------- impl FromJson for Usage { @@ -148,6 +155,7 @@ impl ToJson for Usage { } } +// ---- 消息 ---- // ---- messages ------------------------------------------------------------- impl ToJson for Message { @@ -249,6 +257,8 @@ impl FromJson for Message { } } +/// 把 `content` 字段(数组或字符串)解析为内容块列表;缺失时返回空。 +/// Parse the `content` field (array or string) into content blocks; empty when absent. fn parse_content_array(value: Option<&JsonValue>) -> CoreResult> { match value { Some(JsonValue::Arr(items)) => items.iter().map(ContentBlock::from_json).collect(), @@ -258,11 +268,13 @@ fn parse_content_array(value: Option<&JsonValue>) -> CoreResult JsonValue { JsonValue::Arr(messages.iter().map(ToJson::to_json).collect()) } +/// 反序列化消息列表。 /// Deserialize a list of messages. pub fn messages_from_json(value: &JsonValue) -> CoreResult> { match value { diff --git a/crates/focus-core/src/lib.rs b/crates/focus-core/src/lib.rs index ca2d1d2..626f0e5 100644 --- a/crates/focus-core/src/lib.rs +++ b/crates/focus-core/src/lib.rs @@ -1,5 +1,9 @@ +//! focus-core:agent 层。 //! focus-core: the agent layer. //! +//! 本 crate 是框架的核心。它定义了领域类型(消息、内容块、工具调用、流事件)、 +//! [`Tool`] 与 [`StreamProvider`] trait,以及驱动对话循环的 [`Agent`]。 +//! 它不依赖任何 I/O——provider 与工具都通过 trait 注入。 //! This crate is the heart of the framework. It defines the domain types //! (messages, content blocks, tool calls, stream events), the [`Tool`] and //! [`StreamProvider`] traits, and the [`Agent`] that drives the conversation @@ -7,14 +11,30 @@ #![forbid(unsafe_code)] +/// 双层 agent 循环(steering + follow-up)与批处理工具执行。 +/// The double-loop agent (steering + follow-up) and batched tool execution. pub mod agent; +/// core 层的错误类型。 +/// Error types for the core layer. pub mod error; +/// agent 向外发出的事件与事件 sink。 +/// Agent-emitted events and the event sink. pub mod event; +/// 领域类型与手写 JSON 之间的转换。 +/// Conversions between domain types and hand-written JSON. pub mod json; +/// 领域类型:消息、内容块、工具调用、停止原因等。 +/// Domain types: messages, content blocks, tool calls, stop reasons, … pub mod model; +/// `StreamProvider` trait 与流事件归约器。 +/// The `StreamProvider` trait and the stream-event reducer. pub mod provider; +/// `Tool` trait、效果位标记与工具注册表。 +/// The `Tool` trait, effects bitflag, and tool registry. pub mod tool; +// 仅在测试或 mock feature 下编译。 +// Compiled only under test or the mock feature. #[cfg(any(test, feature = "mock"))] pub mod mock; diff --git a/crates/focus-core/src/mock.rs b/crates/focus-core/src/mock.rs index a84ee71..697dbca 100644 --- a/crates/focus-core/src/mock.rs +++ b/crates/focus-core/src/mock.rs @@ -1,5 +1,8 @@ +//! 一个用于在不触网的情况下测试 agent 循环的 mock [`StreamProvider`]。 //! A mock [`StreamProvider`] for testing the agent loop without any network. //! +//! 这是框架中最重要的测试工具:它让我们用脚本化的 [`StreamEvent`] 序列驱动循环, +//! 并对结果对话记录与已发出的事件做断言。 //! This is the single most important testing tool in the framework: it lets us //! drive the loop through scripted [`StreamEvent`] sequences and assert on the //! resulting transcript and emitted events. @@ -11,21 +14,26 @@ use crate::model::*; use crate::provider::{ProviderRequest, StreamEvent, StreamIterator, StreamProvider, StreamResult}; use std::sync::{Arc, Mutex}; +/// 构造一个回放「响应列表」的 mock provider——每个响应是一个回合的事件列表。 /// Build a mock provider that replays a list of *responses*, where each /// response is a list of events for one assistant turn. #[derive(Debug, Clone)] pub struct MockProvider { + /// 响应队列;每次回合消费一个。 /// Queue of responses; each response is consumed by one turn. responses: Arc>>>, } impl MockProvider { + /// 用一组响应构造 mock provider。 + /// Build a mock provider from a list of responses. pub fn new(responses: Vec>) -> Self { Self { responses: Arc::new(Mutex::new(responses)), } } + /// 一个总是返回单条文本响应(带停止原因)的 provider。 /// A provider that always returns a single text response with stop reason. pub fn single_text(text: &str, stop: StopReason) -> Self { Self::new(vec![make_text_response(text, stop)]) @@ -52,6 +60,8 @@ impl StreamProvider for MockProvider { } } +/// 一个回放固定事件序列的流。 +/// A stream that replays a fixed event sequence. struct MockStream { events: Vec, } @@ -66,6 +76,7 @@ impl StreamIterator for MockStream { } } +/// 构造一个简单的文本响应事件序列。 /// Build a simple text-response event sequence. pub fn make_text_response(text: &str, stop: StopReason) -> Vec { let model = "mock-model".to_string(); @@ -117,6 +128,7 @@ pub fn make_text_response(text: &str, stop: StopReason) -> Vec { ] } +/// 构造一个工具调用响应:模型请求调用一个工具。 /// Build a tool-call response: the model asks to call a tool. pub fn make_tool_call_response( thinking_text: Option<&str>, diff --git a/crates/focus-core/src/model.rs b/crates/focus-core/src/model.rs index da7d27d..3cbf146 100644 --- a/crates/focus-core/src/model.rs +++ b/crates/focus-core/src/model.rs @@ -1,55 +1,94 @@ +//! 领域类型:消息、内容块、工具调用、用量、停止原因。 //! Domain types: messages, content blocks, tool calls, usage, stop reasons. //! +//! 这些类型对应 pi 的 `pi-ai/types.ts` 与 `agent/types.ts`。所有类型都实现了 +//! 与 [`JsonValue`] 之间的手写转换(见 [`crate::json`])。 //! These mirror pi's `pi-ai/types.ts` and `agent/types.ts`. All types implement //! hand-written conversions to/from [`JsonValue`] (see [`crate::json`]). use std::fmt; +// ---- 内容块 ---- // ---- content blocks ------------------------------------------------------- +/// 消息内的一个内容块。按 `type` 标记,对应 pi 的 `ContentBlock`。 /// A block of content inside a message. Tagged on `type` like pi's /// `ContentBlock`. #[derive(Debug, Clone, PartialEq)] pub enum ContentBlock { + /// 纯文本。 /// Plain text. Text(TextContent), + /// 模型推理(扩展思考)。 /// Model reasoning (extended thinking). Thinking(ThinkingContent), + /// 图片(base64)。仅在 user 与 tool-result 消息中合法。 /// An image (base64). Only valid in user and tool-result messages. Image(ImageContent), + /// assistant 请求调用一个工具。 /// A request from the assistant to call a tool. ToolCall(ToolCall), } +/// 纯文本内容块。 +/// Plain-text content block. #[derive(Debug, Clone, PartialEq)] pub struct TextContent { + /// 文本内容。 + /// The text. pub text: String, + /// 可选签名(用于 thinking/redo 校验)。 + /// Optional signature (for thinking/redo verification). pub signature: Option, } +/// 模型思考内容块。 +/// Model thinking content block. #[derive(Debug, Clone, PartialEq)] pub struct ThinkingContent { + /// 思考内容文本。 + /// The thinking text. pub thinking: String, + /// 可选签名。 + /// Optional signature. pub signature: Option, + /// 是否为已被涂黑(redacted)的思考。 + /// Whether the thinking was redacted. pub redacted: bool, } +/// 图片内容块(base64 编码)。 +/// Image content block (base64-encoded). #[derive(Debug, Clone, PartialEq)] pub struct ImageContent { + /// base64 编码的图片数据。 + /// base64-encoded image data. pub data: String, + /// MIME 类型。 + /// The MIME type. pub mime_type: String, } +/// assistant 发出的工具调用。`arguments` 以原始 [`JsonValue`] 存储, +/// 因为各工具自定义其参数形状。 /// A tool call issued by the assistant. `arguments` is stored as a raw /// [`JsonValue`] because tools define their own argument shapes. #[derive(Debug, Clone, PartialEq)] pub struct ToolCall { + /// 本次调用的唯一 id(与后续 tool_result 对应)。 + /// Unique id of this call (matched by the later tool_result). pub id: String, + /// 工具名。 + /// The tool name. pub name: String, + /// 工具参数(原始 JSON)。 + /// The tool arguments (raw JSON). pub arguments: focus_json::JsonValue, } impl ContentBlock { + /// 用文本构造一个 `Text` 内容块。 + /// Build a `Text` content block from a string. pub fn text(s: impl Into) -> Self { ContentBlock::Text(TextContent { text: s.into(), @@ -57,6 +96,8 @@ impl ContentBlock { }) } + /// 若为文本块,借用为 [`TextContent`]。 + /// Borrow as [`TextContent`] if this is a text block. pub fn as_text(&self) -> Option<&TextContent> { match self { ContentBlock::Text(t) => Some(t), @@ -64,6 +105,8 @@ impl ContentBlock { } } + /// 若为工具调用块,借用为 [`ToolCall`]。 + /// Borrow as [`ToolCall`] if this is a tool-call block. pub fn as_tool_call(&self) -> Option<&ToolCall> { match self { ContentBlock::ToolCall(tc) => Some(tc), @@ -72,8 +115,10 @@ impl ContentBlock { } } +// ---- 消息 ---- // ---- messages ------------------------------------------------------------- +/// 对话记录中的一条消息。按 `role` 标记。 /// One message in the conversation transcript. Tagged on `role`. #[derive(Debug, Clone, PartialEq)] pub enum Message { @@ -82,33 +127,69 @@ pub enum Message { ToolResult(ToolResultMessage), } +/// 用户消息。 +/// A user message. #[derive(Debug, Clone, PartialEq)] pub struct UserMessage { + /// 内容块列表。 + /// The content blocks. pub content: Vec, + /// 毫秒级时间戳。 + /// Millisecond timestamp. pub timestamp: u64, } +/// assistant 消息。 +/// An assistant message. #[derive(Debug, Clone, PartialEq)] pub struct AssistantMessage { + /// 内容块列表。 + /// The content blocks. pub content: Vec, + /// 生成该消息的模型名。 + /// The model that produced this message. pub model: String, + /// 本次用量统计。 + /// Usage counters for this turn. pub usage: Usage, + /// 停止原因。 + /// Why the turn ended. pub stop_reason: StopReason, + /// 若出错,记录错误信息。 + /// Error message, if the turn errored. pub error_message: Option, + /// 毫秒级时间戳。 + /// Millisecond timestamp. pub timestamp: u64, } +/// 工具结果消息。 +/// A tool-result message. #[derive(Debug, Clone, PartialEq)] pub struct ToolResultMessage { + /// 对应的工具调用 id。 + /// The tool-call id this result answers. pub tool_call_id: String, + /// 工具名。 + /// The tool name. pub tool_name: String, + /// 内容块列表。 + /// The content blocks. pub content: Vec, + /// 结构化细节(原始 JSON)。 + /// Structured details (raw JSON). pub details: focus_json::JsonValue, + /// 是否为错误结果。 + /// Whether this is an error result. pub is_error: bool, + /// 毫秒级时间戳。 + /// Millisecond timestamp. pub timestamp: u64, } impl Message { + /// 返回角色字符串:`"user"` / `"assistant"` / `"toolResult"`。 + /// Return the role string: `"user"` / `"assistant"` / `"toolResult"`. pub fn role(&self) -> &'static str { match self { Message::User(_) => "user", @@ -117,6 +198,8 @@ impl Message { } } + /// 便捷构造:一条仅含文本的 user 消息。 + /// Convenience constructor: a user message containing a single text block. pub fn user_text(text: impl Into) -> Self { Message::User(UserMessage { content: vec![ContentBlock::text(text)], @@ -125,32 +208,57 @@ impl Message { } } +// ---- 用量 / 成本 ---- // ---- usage / cost --------------------------------------------------------- +/// 一次回合的 token 用量统计。 +/// Token usage counters for one turn. #[derive(Debug, Clone, PartialEq, Default)] pub struct Usage { + /// 输入 token 数。 + /// Input token count. pub input_tokens: u64, + /// 输出 token 数。 + /// Output token count. pub output_tokens: u64, + /// 命中缓存读取的 token 数。 + /// Cache-read token count. pub cache_read_tokens: u64, + /// 触发缓存写入的 token 数。 + /// Cache-write token count. pub cache_write_tokens: u64, } impl Usage { + /// 输入与输出 token 之和(不含缓存)。 + /// Sum of input and output tokens (excluding cache). pub fn total_tokens(&self) -> u64 { self.input_tokens + self.output_tokens } } +// ---- 停止原因 ---- // ---- stop reason ---------------------------------------------------------- +/// assistant 回合结束的原因。 /// Why the assistant turn ended. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum StopReason { #[default] + /// 正常停止。 + /// Normal stop. Stop, + /// 达到长度上限。 + /// Length limit reached. Length, + /// 触发工具调用。 + /// A tool call was made. ToolUse, + /// 出错。 + /// An error occurred. Error, + /// 被中止。 + /// Aborted. Aborted, } @@ -161,6 +269,8 @@ impl fmt::Display for StopReason { } impl StopReason { + /// 返回线路上使用的字符串形式。 + /// Return the wire-format string. pub fn as_str(&self) -> &'static str { match self { StopReason::Stop => "stop", @@ -171,6 +281,8 @@ impl StopReason { } } + /// 由字符串解析为 [`StopReason`]。 + /// Parse a string into a [`StopReason`]. pub fn parse_str(s: &str) -> Option { match s { "stop" => Some(StopReason::Stop), @@ -182,14 +294,17 @@ impl StopReason { } } + /// 若该原因表示必须结束运行的致命回合,则返回 true。 /// True if the reason indicates a fatal turn that must end the run. pub fn is_fatal(&self) -> bool { matches!(self, StopReason::Error | StopReason::Aborted) } } +// ---- 辅助 ---- // ---- helpers -------------------------------------------------------------- +/// 距 Unix 纪元的当前毫秒时间戳。纯 std 实现。 /// Current time in milliseconds since the Unix epoch. Pure std. pub fn now_ms() -> u64 { std::time::SystemTime::now() diff --git a/crates/focus-core/src/provider.rs b/crates/focus-core/src/provider.rs index d17df0f..7654f14 100644 --- a/crates/focus-core/src/provider.rs +++ b/crates/focus-core/src/provider.rs @@ -1,6 +1,11 @@ +//! [`StreamProvider`] trait——agent 循环与具体 LLM 后端之间的 I/O 边界。 //! The [`StreamProvider`] trait — the I/O boundary between the agent loop and //! concrete LLM backends. //! +//! provider 以 [`StreamEvent`] 流的形式输出。它们**绝不**通过返回 `Err` 来表示 +//! API 失败——失败被编码为带相应 [`StopReason`](crate::model::StopReason) 的 +//! [`StreamEvent::Error`]/[`StreamEvent::Done`]。返回 `Err` 仅留给真正意料之外 +//! 的情况(例如 provider 无法被构造)。 //! Providers stream [`StreamEvent`]s. They must **never** return an `Err` to //! signal an API failure — failures are encoded as //! [`StreamEvent::Error`]/[`StreamEvent::Done`] with an appropriate @@ -12,77 +17,105 @@ use crate::model::*; use focus_json::JsonValue; use std::fmt; +/// agent 向 provider 发出的请求。 /// A request the agent makes to a provider. #[derive(Debug, Clone)] pub struct ProviderRequest { + /// 模型 id。 + /// The model id. pub model: String, + /// 系统提示词。 + /// The system prompt. pub system_prompt: String, + /// 对话消息列表。 + /// The conversation messages. pub messages: Vec, + /// 工具定义(原始 JSON)。 + /// Tool definitions (raw JSON). pub tools: JsonValue, + /// 最大输出 token 数。 + /// Max output tokens. pub max_tokens: Option, + /// 采样温度。 + /// Sampling temperature. pub temperature: Option, } +/// provider 流式响应过程中发出的事件。 /// Events emitted by a provider while streaming a response. /// +/// 每个携带 delta 的变体都会在 `partial` 中携带当前完整的 [`AssistantMessage`] +/// 快照,对应 pi 的协议——这样循环本身无需累积 delta。 /// Every variant that carries a delta also carries the current full /// [`AssistantMessage`] snapshot in `partial`, mirroring pi's protocol so the /// loop never needs to accumulate deltas itself. #[derive(Debug, Clone)] pub enum StreamEvent { + /// 响应已开始;附带一个空/部分的 assistant 消息。 /// The response has started; here is an empty/partial assistant message. Start { partial: AssistantMessage }, + /// 在 `content_index` 处打开了一个文本块。 /// A text block opened at `content_index`. TextStart { content_index: usize, partial: AssistantMessage, }, + /// 向 `content_index` 处的文本块追加了文本。 /// Text appended to the block at `content_index`. TextDelta { content_index: usize, delta: String, partial: AssistantMessage, }, + /// `content_index` 处的文本块结束。 /// The text block at `content_index` finished. TextEnd { content_index: usize, partial: AssistantMessage, }, + /// 打开了一个思考块。 /// A thinking block opened. ThinkingStart { content_index: usize, partial: AssistantMessage, }, + /// 追加了思考文本。 /// Thinking text appended. ThinkingDelta { content_index: usize, delta: String, partial: AssistantMessage, }, + /// 思考块结束。 /// The thinking block finished. ThinkingEnd { content_index: usize, partial: AssistantMessage, }, + /// 打开了一个工具调用块。 /// A tool-call block opened. ToolCallStart { content_index: usize, partial: AssistantMessage, }, + /// 向工具调用追加了部分 JSON 参数。 /// Partial JSON arguments appended to the tool call. ToolCallDelta { content_index: usize, delta: String, partial: AssistantMessage, }, + /// 工具调用结束,附带最终的 [`ToolCall`]。 /// The tool call finished with its final [`ToolCall`]. ToolCallEnd { content_index: usize, tool_call: ToolCall, partial: AssistantMessage, }, + /// 响应正常完成。 /// The response completed normally. Done { message: AssistantMessage }, + /// 响应失败或被中止。 /// The response failed or was aborted. Error { error: AssistantMessage }, } @@ -106,9 +139,14 @@ impl fmt::Display for StreamEvent { } } +/// 一个辅助器:把 provider 的增量 delta 累积进 [`AssistantMessage`], +/// 并发出带最新 partial 的 [`StreamEvent`]。 /// A helper that accumulates incremental provider deltas into an /// [`AssistantMessage`] and emits [`StreamEvent`]s with up-to-date partials. /// +/// 像 OpenAI 这样的 provider 会以扁平序列流式输出内容/工具调用 delta,且没有显式 +/// 的 block-start/stop 标记;本 reducer 填补这一空缺,使 provider 只需喂入文本/ +/// 工具 delta 和一个 finish reason。 /// Providers like OpenAI stream content/tool-call deltas in a flat sequence /// without explicit block-start/stop markers; this reducer fills that gap so a /// provider only needs to feed text/tool deltas and a finish reason. @@ -137,6 +175,8 @@ impl ProviderEventReducer { } } + /// 用当前累积状态构造一条 partial [`AssistantMessage`] 快照。 + /// Build a partial [`AssistantMessage`] snapshot from the current accumulator. fn partial(&self) -> AssistantMessage { AssistantMessage { content: self.content.clone(), @@ -148,6 +188,8 @@ impl ProviderEventReducer { } } + /// 首次调用时发出 `Start` 事件;之后返回 `None`。 + /// Emit the `Start` event on first call; `None` thereafter. fn ensure_started(&mut self) -> Option { if !self.started { self.started = true; @@ -158,6 +200,7 @@ impl ProviderEventReducer { None } + /// 喂入一个文本 delta;按需打开 Start + TextStart。 /// Feed a text delta. Opens a Start + TextStart as needed. pub fn text_delta(&mut self, text: &str) -> Vec { let mut out = Vec::new(); @@ -186,6 +229,7 @@ impl ProviderEventReducer { out } + /// 在 `index`(provider 本地索引,映射到一个新内容槽)处打开工具调用块。 /// Open a tool-call block at `index` (provider-local index, mapped to a /// new content slot). pub fn tool_call_start(&mut self, _index: usize, name: &str) -> Vec { @@ -206,6 +250,7 @@ impl ProviderEventReducer { out } + /// 向 `index` 处的工具调用追加部分 JSON 参数。 /// Append partial JSON arguments to the tool call at `index`. pub fn tool_call_delta(&mut self, _index: usize, args: &str) -> Vec { let mut out = Vec::new(); @@ -221,12 +266,16 @@ impl ProviderEventReducer { out } + /// 设置停止原因。 + /// Set the stop reason. pub fn set_stop(&mut self, reason: StopReason) { self.stop_reason = reason; } - /// Finalize: close any open tool calls, return the Done event. + /// 收尾:关闭所有未闭合的工具调用,返回最终的 Done 消息。 + /// Finalize: close any open tool calls, return the final message. pub fn finish(&mut self) -> Option { + // 通过解析累积的 args 来关闭工具调用。 // Close tool calls by parsing their accumulated args. for (i, args) in self.partial_args.iter().enumerate() { if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) { @@ -238,8 +287,10 @@ impl ProviderEventReducer { Some(msg) } + /// 为所有未闭合的内容块(文本/工具调用)发出 end 事件;之后调用方应再调用 + /// [`finish`](Self::finish) 取得最终 Done。按顺序返回这些 end 事件。 /// Emit end events for any open content blocks (text/tool calls), then - /// callers should call [`finish`] for the final Done. Returns the end + /// callers should call [`finish`](Self::finish) for the final message. Returns the end /// events in order. pub fn finalize_events(&mut self) -> Vec { let mut out = Vec::new(); @@ -250,6 +301,7 @@ impl ProviderEventReducer { }); self.text_open = false; } + // 关闭每个工具调用块。 // Close each tool call block. for (i, args) in self.partial_args.iter().enumerate() { if let Some(ContentBlock::ToolCall(tc)) = self.content.get_mut(i) { @@ -266,24 +318,31 @@ impl ProviderEventReducer { } } +/// provider 产出的、装箱后的事件流。provider 持有底层连接/驱动循环; +/// agent 通过 [`StreamIterator::next_event`] 拉取事件。 /// The boxed stream of events a provider produces. The provider owns the /// underlying connection/drive loop; the agent pulls events via /// [`StreamIterator::next_event`]. pub type StreamResult = CoreResult>; +/// 流事件的同步迭代器。provider 驱动其完成;agent 循环拉取事件直到 `None`。 /// A sync iterator over stream events. The provider drives this to completion; /// the agent loop pulls events until `None`. pub trait StreamIterator { fn next_event(&mut self) -> Option; } +/// 每个 LLM 后端都实现的 trait。 /// The trait every LLM backend implements. pub trait StreamProvider: Send + Sync + fmt::Debug { + /// 开始流式响应。返回的迭代器会持续产出事件,直到终止性的 `Done`/`Error`。 /// Begin streaming a response. The returned iterator yields events until a /// terminal `Done`/`Error`. fn stream(&self, request: &ProviderRequest) -> StreamResult; } +/// 装箱的 provider 本身也是 provider,这样 [`Agent`](crate::agent::Agent) +/// 可以用 `Box::new(some_provider)` 构造。 /// A boxed provider is itself a provider, so the [`Agent`](crate::agent::Agent) /// can be constructed with `Box::new(some_provider)`. impl StreamProvider for Box { diff --git a/crates/focus-core/src/tool.rs b/crates/focus-core/src/tool.rs index 9a44193..46aded4 100644 --- a/crates/focus-core/src/tool.rs +++ b/crates/focus-core/src/tool.rs @@ -1,5 +1,9 @@ +//! [`Tool`] trait 与 [`ToolRegistry`]。 //! The [`Tool`] trait and [`ToolRegistry`]. //! +//! 工具是 agent 的双手。一个工具接收经过校验的 JSON 参数,返回一个 +//! [`ToolResult`] 以及可选的增量 [`ToolUpdate`]。工具能否与其他工具并发执行, +//! 通过 [`ToolEffects`] 声明。 //! Tools are the agent's hands. A tool receives validated JSON arguments and //! returns a [`ToolResult`] plus optional incremental [`ToolUpdate`]s. Whether a //! tool may run concurrently with others is declared via [`ToolEffects`]. @@ -9,21 +13,28 @@ use crate::model::*; use focus_json::JsonValue; use std::fmt; +/// 工具执行的最终结果。 /// The final result of a tool execution. #[derive(Debug, Clone)] pub struct ToolResult { + /// 模型能看到的内容(文本/图片块)。 /// What the model sees (text/image blocks). pub content: Vec, + /// UI/日志能看到的内容,对模型不可见。 /// What the UI/logs see, opaque to the model. pub details: JsonValue, + /// 本次结果新引入的工具名(延迟加载)。 /// Names of tools newly introduced by this result (deferred loading). pub added_tool_names: Vec, + /// 在本批次后停止 agent 的提示(仅当本批次所有结果都同意时才生效)。 /// Hint to stop the agent after this batch (only honored if every result /// in the batch agrees). pub terminate: bool, } impl ToolResult { + /// 用一段文本构造一个普通结果。 + /// Build a plain text result. pub fn text(text: impl Into) -> Self { ToolResult { content: vec![ContentBlock::text(text)], @@ -33,6 +44,8 @@ impl ToolResult { } } + /// 用一段文本构造一个错误结果。 + /// Build an error result from a text message. pub fn error(text: impl Into) -> Self { ToolResult { content: vec![ContentBlock::text(text)], @@ -43,45 +56,64 @@ impl ToolResult { } } +/// 工具运行过程中发出的增量更新(例如流式 stdout)。 /// An incremental update emitted while a tool runs (e.g. streaming stdout). #[derive(Debug, Clone)] pub struct ToolUpdate { + /// 内容块。 + /// Content blocks. pub content: Vec, + /// 结构化细节。 + /// Structured details. pub details: JsonValue, } +/// 副作用分类,用于并行安全调度。 /// Side-effect classification used for parallel-safety scheduling. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct ToolEffects(pub u8); impl ToolEffects { + // 各位含义:READ=读、WRITE=写、NETWORK=网络、PROCESS=进程。 + // Bit meaning: READ=read, WRITE=write, NETWORK=network, PROCESS=process. pub const NONE: ToolEffects = ToolEffects(0); pub const READ: ToolEffects = ToolEffects(1 << 0); pub const WRITE: ToolEffects = ToolEffects(1 << 1); pub const NETWORK: ToolEffects = ToolEffects(1 << 2); pub const PROCESS: ToolEffects = ToolEffects(1 << 3); + /// 默认 fail-closed:未声明副作用的工具被视为有副作用(不会与其他工具并发)。 /// Default to fail-closed: a tool that doesn't declare effects is treated /// as having side effects (so it won't run concurrently with others). pub fn write() -> Self { ToolEffects(ToolEffects::WRITE.0) } + /// 是否包含 `other` 的所有位。 + /// Whether all bits of `other` are set. pub fn contains(self, other: Self) -> bool { (self.0 & other.0) == other.0 } + /// 当且仅当两者都不在对方读写时写、都不触网、都不开进程时,两次工具调用才兼容 + /// (可并行执行)。 /// Two tool calls are compatible (can run in parallel) iff neither writes /// while the other reads or writes, and neither touches the network or /// spawns a process. pub fn compatible_with(self, other: Self) -> bool { + // 写冲突:任一方写、且双方存在交互。 + // Write conflict: either side writes and the two interact. let writes_conflict = (self.contains(ToolEffects::WRITE) || other.contains(ToolEffects::WRITE)) && (self.0 | other.0 != 0 || self.contains(ToolEffects::WRITE) && other.contains(ToolEffects::WRITE)); + // 写与读冲突:一方写、另一方存在非写操作。 + // Write-vs-read conflict: one side writes, the other does something non-write. let write_vs_read = self.contains(ToolEffects::WRITE) && (other.0 & !ToolEffects::WRITE.0 != 0) || other.contains(ToolEffects::WRITE) && (self.0 & !ToolEffects::WRITE.0 != 0); + // 副作用:任一方触网或开进程。 + // Side effects: either side touches the network or spawns a process. let side_effects = self.contains(ToolEffects::NETWORK) || other.contains(ToolEffects::NETWORK) || self.contains(ToolEffects::PROCESS) @@ -113,25 +145,37 @@ impl fmt::Display for ToolEffects { } } +/// agent 可调用的工具。 /// A tool the agent can call. /// +/// 实现必须廉价可克隆(通常是 `Arc` 支撑的共享状态),因为注册表在派发时会克隆工具。 /// Implementations must be cheap to clone (typically `Arc`-backed shared /// state) because the registry clones tools when dispatching. pub trait Tool: Send + Sync + fmt::Debug { + /// 工具名(模型调用时使用)。 + /// The tool name (used when the model calls it). fn name(&self) -> &str; + /// 显示标签,默认与 [`name`](Self::name) 相同。 + /// Display label, defaults to the [`name`](Self::name). fn label(&self) -> &str { self.name() } + /// 给模型看的工具描述。 + /// Description shown to the model. fn description(&self) -> &str; + /// 描述模型应提供参数的 JSON schema。 /// JSON schema describing the arguments the model should provide. fn parameters(&self) -> JsonValue; + /// 用于调度的副作用分类。 /// Side-effect classification for scheduling. fn effects(&self) -> ToolEffects { + // Fail-closed 默认值。 // Fail-closed default. ToolEffects::write() } + /// 运行工具。`on_update` 可在返回的 future 完成之前被调用以推送部分结果。 /// Run the tool. `on_update` may be invoked with partial results before the /// returned future resolves. fn execute( @@ -142,9 +186,11 @@ pub trait Tool: Send + Sync + fmt::Debug { ) -> CoreResult; } +/// 工具用来推送增量更新的 channel 式 sink。 /// A channel-style sink a tool uses to push incremental updates. pub type ToolUpdateSink = dyn Fn(ToolUpdate) + Send + Sync; +/// 可用工具的注册表,按名查找。 /// A registry of available tools, looked up by name. #[derive(Default)] pub struct ToolRegistry { @@ -160,20 +206,28 @@ impl fmt::Debug for ToolRegistry { } impl ToolRegistry { + /// 创建一个空注册表。 + /// Create an empty registry. pub fn new() -> Self { Self::default() } + /// 用单个工具构造注册表。 + /// Build a registry containing a single tool. pub fn with(tool: Box) -> Self { let mut r = Self::new(); r.tools.push(tool); r } + /// 注册一个工具。 + /// Register a tool. pub fn register(&mut self, tool: Box) { self.tools.push(tool); } + /// 按名查找工具。 + /// Look up a tool by name. pub fn get(&self, name: &str) -> Option<&dyn Tool> { self.tools .iter() @@ -181,18 +235,25 @@ impl ToolRegistry { .map(|t| t.as_ref()) } + /// 遍历所有工具。 + /// Iterate over all tools. pub fn iter(&self) -> impl Iterator { self.tools.iter().map(|t| t.as_ref()) } + /// 工具数量。 + /// Number of tools. pub fn len(&self) -> usize { self.tools.len() } + /// 是否为空。 + /// Whether the registry is empty. pub fn is_empty(&self) -> bool { self.tools.is_empty() } + /// 构造发送给模型的工具定义 JSON 数组。 /// Build the JSON array of tool definitions sent to the model. pub fn tool_definitions(&self) -> JsonValue { JsonValue::Arr( @@ -265,8 +326,10 @@ mod tests { #[test] fn read_effects_compatible() { assert!(ToolEffects::READ.compatible_with(ToolEffects::READ)); + // 写与读不兼容。 // write vs read is not compatible assert!(!ToolEffects::WRITE.compatible_with(ToolEffects::READ)); + // 触网/开进程会阻止并行。 // network/process block parallelism assert!(!ToolEffects::NETWORK.compatible_with(ToolEffects::NONE)); assert!(!ToolEffects::PROCESS.compatible_with(ToolEffects::NONE)); diff --git a/crates/focus-harness/src/lib.rs b/crates/focus-harness/src/lib.rs index 3d69276..9e8b65c 100644 --- a/crates/focus-harness/src/lib.rs +++ b/crates/focus-harness/src/lib.rs @@ -1,5 +1,7 @@ +//! focus-harness:会话持久化、上下文压缩与系统提示词模板。 //! focus-harness: session persistence, compaction, system prompts. //! +//! 本里程碑阶段尚未实现。 //! Not yet implemented in this milestone. #![forbid(unsafe_code)] diff --git a/crates/focus-json/src/error.rs b/crates/focus-json/src/error.rs index 112e1e5..33a2cd8 100644 --- a/crates/focus-json/src/error.rs +++ b/crates/focus-json/src/error.rs @@ -1,11 +1,17 @@ +//! JSON 错误类型。 //! JSON error type. use std::fmt; +/// 解析或序列化 JSON 时产生的错误。 /// Errors produced while parsing or serializing JSON. #[derive(Debug)] pub struct JsonError { + /// 人类可读的错误描述。 + /// Human-readable description of the error. pub message: String, + /// 触发错误的字节偏移量(若可用)。 + /// Byte offset where the error occurred, if known. pub position: Option, } @@ -21,6 +27,8 @@ impl fmt::Display for JsonError { impl std::error::Error for JsonError {} impl JsonError { + /// 创建一个不带位置信息的错误。 + /// Create an error with no position information. pub fn new(message: impl Into) -> Self { Self { message: message.into(), @@ -28,6 +36,8 @@ impl JsonError { } } + /// 创建一个带字节偏移位置的错误。 + /// Create an error with a byte-offset position. pub fn at(message: impl Into, position: usize) -> Self { Self { message: message.into(), @@ -36,5 +46,6 @@ impl JsonError { } } +/// JSON 操作的 `Result` 便捷别名。 /// Convenience `Result` alias for JSON operations. pub type JsonResult = Result; diff --git a/crates/focus-json/src/lib.rs b/crates/focus-json/src/lib.rs index 477f454..2af0a8c 100644 --- a/crates/focus-json/src/lib.rs +++ b/crates/focus-json/src/lib.rs @@ -1,5 +1,9 @@ +//! 精简的 JSON 解析器与序列化器。 //! Minimal JSON parser and serializer. //! +//! 提供 [`JsonValue`] DOM 以及 [`parse`] / [`to_string`] 函数。 +//! 对象键保留插入顺序。支持完整的 JSON 文法(对象、数组、含转义的字符串、 +//! 数字、布尔值、null),但不支持任何扩展(不支持注释、尾随逗号、JSONC)。 //! Provides a [`JsonValue`] DOM and the [`parse`] / [`to_string`] functions. //! Object keys preserve insertion order. Supports the full JSON grammar //! (objects, arrays, strings with escapes, numbers, booleans, null) but no @@ -12,7 +16,15 @@ mod parser; mod value; mod writer; +/// JSON 错误类型与结果别名。 +/// JSON error type and result alias. pub use error::{JsonError, JsonResult}; +/// 解析 JSON 文本的入口函数。 +/// Entry function for parsing JSON text. pub use parser::parse; +/// 表示已解析 JSON 值的动态类型。 +/// Dynamic type representing a parsed JSON value. pub use value::JsonValue; +/// 将 JSON 值序列化为字符串的入口函数。 +/// Entry function for serializing a JSON value to a string. pub use writer::to_string; diff --git a/crates/focus-json/src/parser.rs b/crates/focus-json/src/parser.rs index 8a4a3c7..9529425 100644 --- a/crates/focus-json/src/parser.rs +++ b/crates/focus-json/src/parser.rs @@ -1,5 +1,7 @@ +//! 递归下降的 JSON 解析器。 //! Recursive-descent JSON parser. //! +//! 实现的文法(RFC 8259 子集,无扩展): //! Grammar implemented (RFC 8259 subset, no extensions): //! ```text //! value = object | array | string | number | true | false | null @@ -13,6 +15,7 @@ use crate::error::{JsonError, JsonResult}; use crate::value::JsonValue; +/// 将一份 JSON 文档解析为 [`JsonValue`]。 /// Parse a JSON document into a [`JsonValue`]. pub fn parse(input: &str) -> JsonResult { let bytes = input.as_bytes(); @@ -30,29 +33,47 @@ pub fn parse(input: &str) -> JsonResult { Ok(value) } +/// 递归下降解析器的游标状态。 +/// Cursor state for the recursive-descent parser. struct Parser<'a> { + /// 待解析的字节切片。 + /// The byte slice being parsed. bytes: &'a [u8], + /// 当前字节偏移。 + /// Current byte offset. pos: usize, + /// 当前嵌套深度(用于防御性栈溢出保护)。 + /// Current nesting depth (defensive stack-overflow guard). depth: usize, } +/// 最大嵌套深度,防止恶意输入导致栈溢出。 +/// Maximum nesting depth, guarding against malicious stack overflow. const MAX_DEPTH: usize = 512; impl<'a> Parser<'a> { + /// 在当前位置构造一个带位置信息的错误。 + /// Build an error tagged with the current position. fn error(&self, msg: &str) -> JsonError { JsonError::at(msg, self.pos) } + /// 偷看当前字节(不推进)。 + /// Peek at the current byte without advancing. fn peek(&self) -> Option { self.bytes.get(self.pos).copied() } + /// 读取并消费当前字节。 + /// Read and consume the current byte. fn bump(&mut self) -> Option { let b = self.peek()?; self.pos += 1; Some(b) } + /// 跳过空白字符。 + /// Skip whitespace. fn skip_ws(&mut self) { while let Some(b) = self.peek() { match b { @@ -62,6 +83,8 @@ impl<'a> Parser<'a> { } } + /// 期望并消费一个指定字节;不匹配则报错。 + /// Expect and consume a specific byte, erroring on mismatch. fn expect(&mut self, expected: u8, what: &str) -> JsonResult<()> { match self.peek() { Some(b) if b == expected => { @@ -73,8 +96,12 @@ impl<'a> Parser<'a> { } } + /// 根据首字节分派,解析任意 JSON 值。包含深度检查。 + /// Dispatch on the leading byte to parse any JSON value. Includes a depth check. fn parse_value(&mut self) -> JsonResult { self.skip_ws(); + // 嵌套过深则拒绝,防止栈溢出。 + // Reject nesting that is too deep, to guard the stack. if self.depth >= MAX_DEPTH { return Err(self.error("JSON nesting too deep")); } @@ -96,6 +123,8 @@ impl<'a> Parser<'a> { Ok(value) } + /// 匹配一个字面量关键字(true/false/null)。 + /// Match a literal keyword (true/false/null). fn parse_keyword(&mut self, kw: &str, value: JsonValue) -> JsonResult { let kw_bytes = kw.as_bytes(); if self.bytes.len() < self.pos + kw_bytes.len() { @@ -108,6 +137,8 @@ impl<'a> Parser<'a> { Ok(value) } + /// 解析对象:`"{" [ member *( "," member ) ] ")"`。 + /// Parse an object: `"{" [ member *( "," member ) ] "}"`. fn parse_object(&mut self) -> JsonResult { self.expect(b'{', "'{'")?; let mut entries = Vec::new(); @@ -143,6 +174,8 @@ impl<'a> Parser<'a> { Ok(JsonValue::Obj(entries)) } + /// 解析数组:`"[" [ value *( "," value ) ] "]"`。 + /// Parse an array: `"[" [ value *( "," value ) ] "]"`. fn parse_array(&mut self) -> JsonResult { self.expect(b'[', "'['")?; let mut items = Vec::new(); @@ -174,6 +207,8 @@ impl<'a> Parser<'a> { Ok(JsonValue::Arr(items)) } + /// 解析字符串(含转义与 UTF-8 多字节处理)。 + /// Parse a string (with escapes and multibyte UTF-8 handling). fn parse_string(&mut self) -> JsonResult { self.expect(b'"', "'\"'")?; let mut out = String::new(); @@ -205,10 +240,13 @@ impl<'a> Parser<'a> { } } } + // JSON 字符串中不允许出现控制字符(U+0000..U+001F)。 // Control characters (U+0000..U+001F) are invalid in JSON strings. 0x00..=0x1F => return Err(self.error("unescaped control character in string")), + // ASCII 快速路径。 // ASCII fast path. 0x20..=0x7F => out.push(b as char), + // 多字节 UTF-8:按原样拷贝该序列的剩余字节。 // Multibyte UTF-8: copy remaining bytes of the sequence verbatim. _ => { let extra = utf8_continuation_len(b); @@ -228,9 +266,11 @@ impl<'a> Parser<'a> { Ok(out) } + /// 解析 4 位十六进制的 `\uXXXX` 转义,含其后可能跟随的代理对。 /// Parse a 4-hex-digit `\uXXXX` escape, including a following surrogate pair. fn parse_unicode_escape(&mut self) -> JsonResult { let hi = self.parse_hex4()?; + // 高位代理:其后必须紧跟一个低位代理。 // High surrogate: must be followed by a low surrogate. if (0xD800..=0xDBFF).contains(&hi) { if self.peek() == Some(b'\\') && self.bytes.get(self.pos + 1) == Some(&b'u') { @@ -245,6 +285,7 @@ impl<'a> Parser<'a> { } return Err(self.error("expected low surrogate after high surrogate")); } + // 没有前置高位代理的低位代理是非法的。 // Low surrogate without a preceding high surrogate is invalid. if (0xDC00..=0xDFFF).contains(&hi) { return Err(self.error("unexpected low surrogate")); @@ -252,6 +293,8 @@ impl<'a> Parser<'a> { char::from_u32(hi).ok_or_else(|| self.error("invalid unicode codepoint")) } + /// 解析 4 位十六进制数字。 + /// Parse four hexadecimal digits. fn parse_hex4(&mut self) -> JsonResult { let mut value = 0u32; for _ in 0..4 { @@ -269,12 +312,15 @@ impl<'a> Parser<'a> { Ok(value) } + /// 解析数字:`[ "-" ] int [ frac ] [ exp ]`,最终转为 f64。 + /// Parse a number: `[ "-" ] int [ frac ] [ exp ]`, ultimately as f64. fn parse_number(&mut self) -> JsonResult { let start = self.pos; if self.peek() == Some(b'-') { self.pos += 1; } self.parse_int_part()?; + // 小数部分。 // Fractional part. if self.peek() == Some(b'.') { self.pos += 1; @@ -282,6 +328,7 @@ impl<'a> Parser<'a> { return Err(self.error("expected digit after decimal point")); } } + // 指数部分。 // Exponent. if matches!(self.peek(), Some(b'e') | Some(b'E')) { self.pos += 1; @@ -300,10 +347,14 @@ impl<'a> Parser<'a> { Ok(JsonValue::Num(n)) } + /// 解析整数部分,禁止前导零(如 "01")。 + /// Parse the integer part, disallowing leading zeros (e.g. "01"). fn parse_int_part(&mut self) -> JsonResult<()> { match self.peek() { Some(b'0') => { self.pos += 1; + // 前导零后不得紧跟另一个数字(不允许 "01" 这样的前导零); + // 下一个字符必须是 '.'、'e' 或某个值终止符。 // A leading zero must not be followed by another digit (no // leading zeros like "01"); the next char must be '.', 'e', or // a value terminator. @@ -320,6 +371,7 @@ impl<'a> Parser<'a> { Ok(()) } + /// 消费一个或多个数字;至少消费一个返回 true。 /// Consume one or more digits. Returns true if at least one digit was consumed. fn parse_digit_sequence(&mut self, _what: &str) -> Result { let mut any = false; @@ -331,10 +383,13 @@ impl<'a> Parser<'a> { } } +/// UTF-8 引导字节之后的后续字节数。 +/// ASCII(< 0x80)与孤立的后续字节(< 0xC0)贡献 0。 /// Number of continuation bytes for a UTF-8 lead byte. /// ASCII (< 0x80) and stray continuation bytes (< 0xC0) contribute zero. fn utf8_continuation_len(lead: u8) -> usize { match lead { + // ASCII 或孤立的后续字节。 0..=0xBF => 0, // ASCII or stray continuation byte 0xC0..=0xDF => 1, 0xE0..=0xEF => 2, @@ -420,6 +475,7 @@ mod tests { fn parses_unicode_escapes() { assert_eq!(ok(r#""\u0041""#), JsonValue::Str("A".into())); assert_eq!(ok(r#""\u4e2d""#), JsonValue::Str("中".into())); + // U+1F600 😀 的代理对。 // Surrogate pair for U+1F600 😀 assert_eq!(ok(r#""\uD83D\uDE00""#), JsonValue::Str("😀".into())); } @@ -471,6 +527,7 @@ mod tests { #[test] fn rejects_trailing_comma() { + // 尾随逗号会被拒绝,因为下一个字符(']' 或 '}')不是合法的值/成员起始。 // A trailing comma is rejected because the next char (']' or '}') is // not a valid value/member start. assert!(parse("[1,2,]").is_err()); diff --git a/crates/focus-json/src/value.rs b/crates/focus-json/src/value.rs index ed8345b..fb3b7b6 100644 --- a/crates/focus-json/src/value.rs +++ b/crates/focus-json/src/value.rs @@ -1,5 +1,8 @@ +//! [`JsonValue`] DOM 及其辅助方法。 //! The [`JsonValue`] DOM and its helper methods. //! +//! 对象使用 `Vec<(String, JsonValue)>` 保留插入顺序,而不是哈希表—— +//! 这样序列化结果是确定性的,也与 provider 输出字段的顺序一致。 //! Objects preserve insertion order using a `Vec<(String, JsonValue)>` rather //! than a hash map, which keeps serialization deterministic and matches the //! order in which providers emit fields. @@ -7,6 +10,7 @@ use crate::error::{JsonError, JsonResult}; use std::fmt; +/// [`JsonValue::entries`] 使用的私有双臂迭代器组合子。 /// Private two-arm iterator combinator used by [`JsonValue::entries`]. enum Either { Left(L), @@ -27,6 +31,7 @@ where } } +/// 一个已解析的 JSON 值。 /// A parsed JSON value. #[derive(Debug, Clone, PartialEq, Default)] pub enum JsonValue { @@ -36,37 +41,52 @@ pub enum JsonValue { Num(f64), Str(String), Arr(Vec), + /// 按插入顺序排列的键值对。 /// Key-value pairs in insertion order. Obj(Vec<(String, JsonValue)>), } impl JsonValue { + // ---- 构造函数 ---- // ---- constructors ---------------------------------------------------- + /// 创建一个空对象。 + /// Create an empty object. pub fn obj() -> Self { JsonValue::Obj(Vec::new()) } + /// 创建一个空数组。 + /// Create an empty array. pub fn arr() -> Self { JsonValue::Arr(Vec::new()) } + // ---- 判定 ---- // ---- predicates ------------------------------------------------------- + /// 是否为 null。 + /// Whether this is the null value. pub fn is_null(&self) -> bool { matches!(self, JsonValue::Null) } + /// 是否为对象。 + /// Whether this is an object. pub fn is_object(&self) -> bool { matches!(self, JsonValue::Obj(_)) } + /// 是否为数组。 + /// Whether this is an array. pub fn is_array(&self) -> bool { matches!(self, JsonValue::Arr(_)) } + // ---- 访问器 ---- // ---- accessors -------------------------------------------------------- + /// 按键从对象中获取字段。非对象返回 `None`。 /// Get a field from an object by key. Returns `None` for non-objects. pub fn get(&self, key: &str) -> Option<&JsonValue> { match self { @@ -75,6 +95,7 @@ impl JsonValue { } } + /// 若字段为字符串,则以字符串切片借用。 /// Borrow a field as a string slice if it is a string. pub fn get_str(&self, key: &str) -> Option<&str> { match self.get(key)? { @@ -83,6 +104,7 @@ impl JsonValue { } } + /// 以布尔值借用字段。 /// Borrow a field as a bool. pub fn get_bool(&self, key: &str) -> Option { match self.get(key)? { @@ -91,6 +113,7 @@ impl JsonValue { } } + /// 以数字借用字段。 /// Borrow a field as a number. pub fn get_num(&self, key: &str) -> Option { match self.get(key)? { @@ -99,6 +122,7 @@ impl JsonValue { } } + /// 以数组借用字段。 /// Borrow a field as an array. pub fn get_arr(&self, key: &str) -> Option<&Vec> { match self.get(key)? { @@ -107,6 +131,7 @@ impl JsonValue { } } + /// 以对象借用字段。 /// Borrow a field as an object. pub fn get_obj(&self, key: &str) -> Option<&Vec<(String, JsonValue)>> { match self.get(key)? { @@ -115,6 +140,7 @@ impl JsonValue { } } + /// 迭代对象的键值对;非对象返回空迭代器。 /// Iterate object entries. Empty for non-objects. pub fn entries(&self) -> impl Iterator { match self { @@ -123,8 +149,10 @@ impl JsonValue { } } + // ---- 修改器 ---- // ---- mutators --------------------------------------------------------- + /// 向对象插入键值对;键已存在则替换。`self` 非对象时返回错误。 /// Insert a key-value pair into an object. Replaces if key exists. /// Returns an error if `self` is not an object. pub fn insert(&mut self, key: impl Into, value: JsonValue) -> JsonResult<()> { @@ -142,6 +170,7 @@ impl JsonValue { } } + /// 向数组追加一个值。`self` 非数组时返回错误。 /// Push a value into an array. Returns an error if `self` is not an array. pub fn push(&mut self, value: JsonValue) -> JsonResult<()> { match self { @@ -160,50 +189,67 @@ impl fmt::Display for JsonValue { } } +// ---- 从基本类型的转换 ---- // ---- conversions from primitives ---------------------------------------- +/// 从布尔值构造 JSON 值。 +/// Construct a JSON value from a bool. impl From for JsonValue { fn from(b: bool) -> Self { JsonValue::Bool(b) } } +/// 从字符串切片构造 JSON 值。 +/// Construct a JSON value from a string slice. impl From<&str> for JsonValue { fn from(s: &str) -> Self { JsonValue::Str(s.to_string()) } } +/// 从字符串构造 JSON 值。 +/// Construct a JSON value from a String. impl From for JsonValue { fn from(s: String) -> Self { JsonValue::Str(s) } } +/// 从 f64 构造 JSON 值。 +/// Construct a JSON value from an f64. impl From for JsonValue { fn from(n: f64) -> Self { JsonValue::Num(n) } } +/// 从 i64 构造 JSON 值(转为 f64 存储)。 +/// Construct a JSON value from an i64 (stored as f64). impl From for JsonValue { fn from(n: i64) -> Self { JsonValue::Num(n as f64) } } +/// 从 u64 构造 JSON 值(转为 f64 存储)。 +/// Construct a JSON value from a u64 (stored as f64). impl From for JsonValue { fn from(n: u64) -> Self { JsonValue::Num(n as f64) } } +/// 从 usize 构造 JSON 值(转为 f64 存储)。 +/// Construct a JSON value from a usize (stored as f64). impl From for JsonValue { fn from(n: usize) -> Self { JsonValue::Num(n as f64) } } +/// 从向量构造 JSON 数组(逐元素转换)。 +/// Construct a JSON array from a vector (element-wise conversion). impl> From> for JsonValue { fn from(v: Vec) -> Self { JsonValue::Arr(v.into_iter().map(Into::into).collect()) diff --git a/crates/focus-json/src/writer.rs b/crates/focus-json/src/writer.rs index b5c379c..a1c4f3e 100644 --- a/crates/focus-json/src/writer.rs +++ b/crates/focus-json/src/writer.rs @@ -1,8 +1,10 @@ +//! JSON 序列化器——把 [`JsonValue`] 渲染回字符串。 //! JSON serializer — renders a [`JsonValue`] back to a String. use crate::value::JsonValue; use std::fmt::Write; +/// 将 [`JsonValue`] 序列化为紧凑的 JSON 字符串。 /// Serialize a [`JsonValue`] to a compact JSON string. pub fn to_string(value: &JsonValue) -> String { let mut out = String::new(); @@ -10,6 +12,8 @@ pub fn to_string(value: &JsonValue) -> String { out } +/// 把一个值写入 `out`(递归处理数组与对象)。 +/// Write a value into `out` (recursing into arrays and objects). fn write_value(value: &JsonValue, out: &mut String) { match value { JsonValue::Null => out.push_str("null"), @@ -42,23 +46,31 @@ fn write_value(value: &JsonValue, out: &mut String) { } } +/// 以 JSON 兼容形式渲染数字。整数打印时不带小数部分,符合常见预期 +/// (例如 `3` 而非 `3.0`)。 /// Render a number in a JSON-compatible form. Integers print without a /// fractional part to match common expectations (e.g. `3` not `3.0`). fn write_number(n: f64, out: &mut String) { if n.is_finite() { + // 整数走 i64 分支;浮点数走最短表示分支。 + // Integers take the i64 branch; fractional numbers take the shortest-repr branch. if n.fract() == 0.0 && n.abs() < 1e16 { let _ = write!(out, "{}", n as i64); } else { + // 通过 format! 得到 Ryu 风格的最短表示:可往返且无需引入依赖。 // Use Ryu-style shortest representation via format!: this gives // round-trippable floats without pulling in a dependency. let _ = write!(out, "{}", n); } } else { + // JSON 无法表示无穷或 NaN;输出 null 以保持有效。 // JSON has no representation for infinity or NaN. Emit null to stay valid. out.push_str("null"); } } +/// 将字符串转义并加上引号后写入 `out`。 +/// Escape and quote a string into `out`. fn write_string(s: &str, out: &mut String) { out.push('"'); for c in s.chars() { diff --git a/crates/focus-providers/src/anthropic.rs b/crates/focus-providers/src/anthropic.rs index 8f08518..33cb9df 100644 --- a/crates/focus-providers/src/anthropic.rs +++ b/crates/focus-providers/src/anthropic.rs @@ -1,5 +1,8 @@ +//! Anthropic Messages API provider。 //! Anthropic Messages API provider. //! +//! 将 [`ProviderRequest`] 翻译为 Anthropic 的 `/v1/messages` 流式格式, +//! 并把返回的 SSE 流解析为 [`StreamEvent`]。 //! Translates [`ProviderRequest`] into Anthropic's `/v1/messages` streaming //! format and parses the resulting SSE stream into [`StreamEvent`]s. @@ -14,14 +17,22 @@ use focus_transport::{HttpTransport, Transport}; use std::fmt; use std::sync::Arc; +/// Anthropic provider。`transport` 通过 trait object 泛化,以便测试注入 +/// [`MockTransport`]。 /// The Anthropic provider. `transport` is generic via a trait object so tests /// can inject a [`MockTransport`]. pub struct AnthropicProvider { + /// provider 配置(key、model、base URL 等)。 + /// Provider configuration (key, model, base URL, …). config: ProviderConfig, + /// 实际执行 HTTP 请求的 transport(生产 `HttpTransport`,测试 `MockTransport`)。 + /// Transport performing the HTTP request (`HttpTransport` in prod, `MockTransport` in tests). transport: Arc, } impl AnthropicProvider { + /// 使用真实 HTTP transport 构造。 + /// Build with a real HTTP transport. pub fn new(config: ProviderConfig) -> Self { Self { config, @@ -29,16 +40,22 @@ impl AnthropicProvider { } } + /// 使用自定义 transport 构造(用于测试)。 /// Construct with a custom transport (for tests). pub fn with_transport(config: ProviderConfig, transport: Arc) -> Self { Self { config, transport } } + /// 测试辅助:用预置 SSE 文本的 mock transport 构造。 /// Test helper: build with a mock transport from canned SSE text. pub fn with_mock(config: ProviderConfig, sse_body: impl Into) -> Self { Self::with_transport(config, Arc::new(MockTransport::new(200, sse_body))) } + /// 将 [`ProviderRequest`] 组装为 Anthropic `/v1/messages` 的 JSON 请求体与 + /// HTTP 请求(system 提示作为顶层字段、工具透传)。 + /// Assemble the [`ProviderRequest`] into an Anthropic `/v1/messages` JSON body + /// and HTTP request (system prompt as a top-level field, tools passed through). fn build_request(&self, request: &ProviderRequest) -> focus_transport::HttpRequest { let mut body = JsonValue::obj(); body.insert("model", self.config.model.clone().into()).ok(); @@ -77,6 +94,8 @@ impl fmt::Debug for AnthropicProvider { impl StreamProvider for AnthropicProvider { fn stream(&self, request: &ProviderRequest) -> StreamResult { let http_req = self.build_request(request); + // 我们在此同步执行请求(在一个小型 runtime 上阻塞),因为 core 的 + // StreamIterator 是同步拉取模型。 // We perform the request synchronously here (blocking on a mini // runtime) because the core's StreamIterator is a sync pull model. let response = block_on_request(&self.transport, &http_req)?; @@ -85,6 +104,8 @@ impl StreamProvider for AnthropicProvider { } } +/// 在一个临时单线程 tokio runtime 上同步阻塞地执行一次 HTTP 请求。 +/// Run one HTTP request synchronously by blocking on a throwaway single-thread tokio runtime. fn block_on_request( transport: &Arc, req: &focus_transport::HttpRequest, @@ -97,8 +118,13 @@ fn block_on_request( .map_err(|e| CoreError::Provider(e.to_string())) } +// ---- 消息转换 ---- // ---- message conversion --------------------------------------------------- +/// 将统一消息列表转为 Anthropic 的 messages 数组(ToolResult 映射为 +/// 带 `tool_result` 块的 user 消息)。 +/// Convert the unified message list into Anthropic's messages array (a +/// ToolResult maps to a user message holding a `tool_result` block). fn convert_messages(messages: &[Message]) -> JsonValue { let mut arr = JsonValue::arr(); for m in messages { @@ -133,6 +159,9 @@ fn convert_messages(messages: &[Message]) -> JsonValue { arr } +/// 将内容块转为 Anthropic 的结构化 content 数组(text/thinking/image/tool_use)。 +/// Convert content blocks into Anthropic's structured content array +/// (text/thinking/image/tool_use). fn convert_content_out(blocks: &[ContentBlock]) -> JsonValue { let mut arr = JsonValue::arr(); for b in blocks { @@ -172,27 +201,52 @@ fn convert_content_out(blocks: &[ContentBlock]) -> JsonValue { arr } +/// 工具数组是否为空(缺失或空数组均视为空)。 +/// Whether the tools value is empty (missing or empty array both count). fn tools_is_empty(tools: &JsonValue) -> bool { matches!(tools, JsonValue::Arr(a) if a.is_empty()) } +// ---- SSE 转 StreamEvent ---- // ---- SSE -> StreamEvent --------------------------------------------------- +/// 驱动 Anthropic 的 SSE 响应体并产出 [`StreamEvent`]。 /// Drives an Anthropic SSE body and emits [`StreamEvent`]s. pub struct AnthropicStream { + /// 增量 SSE 解析器。 + /// Incremental SSE parser. parser: focus_transport::SseParser, + /// 当前模型名(用于构造 partial 消息)。 + /// Current model name (used when building partial messages). model: String, + /// 是否已收到终止事件(message_stop / error)。 + /// Whether a terminal event (message_stop / error) has been seen. done: bool, + // 累加状态。 // Accumulator state. + /// 已累积的内容块(按 Anthropic 的 index 顺序)。 + /// Content blocks accumulated so far (in Anthropic's index order). content: Vec, + /// 每个 tool_use 块尚未解析的 partial_json 片段。 + /// Per-block (tool_use) not-yet-parsed partial_json fragments. partial_json: Vec, + /// 当前用量统计。 + /// Current usage counters. usage: Usage, + /// 当前停止原因。 + /// Current stop reason. stop_reason: StopReason, + /// 若收到 error 事件,记录其消息。 + /// Message captured from an error event, if any. error_message: Option, + /// 已产生但尚未被 `next_event` 取走的事件缓冲。 + /// Buffer of produced events not yet drained by `next_event`. pending: std::collections::VecDeque, } impl AnthropicStream { + /// 用完整响应体字符串与模型名构造(SSE 一次性喂入)。 + /// Build from the full response-body string and model name (whole SSE body fed at once). fn new(body: &str, model: &str) -> Self { let mut parser = focus_transport::SseParser::new(); parser.feed_str(body); @@ -209,6 +263,9 @@ impl AnthropicStream { } } + /// 用当前累加状态快照构造一条 [`AssistantMessage`](作为事件中的 partial)。 + /// Build an [`AssistantMessage`] snapshot from the current accumulator state + /// (used as the `partial` field on events). fn current_partial(&self) -> AssistantMessage { AssistantMessage { content: self.content.clone(), @@ -220,6 +277,9 @@ impl AnthropicStream { } } + /// 处理一个 Anthropic SSE 事件(按 `event_type` 分派),返回本次产生的事件列表。 + /// Handle one Anthropic SSE event (dispatched by `event_type`); returns the + /// list of events it produced. fn handle_event(&mut self, event_type: &str, data: &str) -> Vec { let mut out = Vec::new(); let value = match parse(data) { @@ -228,6 +288,7 @@ impl AnthropicStream { }; match event_type { "message_start" => { + // 如存在 message.usage,则从中初始化用量统计。 // seed usage from message.usage if present if let Some(u) = value.get("message").and_then(|m| m.get("usage")) { self.usage.input_tokens = u.get_num("input_tokens").unwrap_or(0.0) as u64; @@ -339,6 +400,7 @@ impl AnthropicStream { partial: self.current_partial(), }), Some(ContentBlock::ToolCall(tc)) => { + // 用累积的 partial json 最终确定工具参数。 // finalize arguments from accumulated partial json let pj = self.partial_json.get(idx).cloned().unwrap_or_default(); tc.arguments = parse(&pj).unwrap_or(JsonValue::obj()); @@ -390,6 +452,9 @@ impl AnthropicStream { } impl StreamIterator for AnthropicStream { + /// 拉取下一个事件:先排空 `pending`;缓冲空则取下一个 SSE 事件并处理。 + /// Pull the next event: drain `pending` first; when empty, pull the next + /// SSE event and handle it. fn next_event(&mut self) -> Option { loop { if let Some(ev) = self.pending.pop_front() { @@ -408,6 +473,8 @@ impl StreamIterator for AnthropicStream { } } +/// 将 Anthropic 的 stop_reason 字符串映射为统一的 [`StopReason`]。 +/// Map an Anthropic stop_reason string to the unified [`StopReason`]. fn map_stop_reason(s: &str) -> StopReason { match s { "end_turn" | "stop_sequence" | "pause_turn" => StopReason::Stop, diff --git a/crates/focus-providers/src/common.rs b/crates/focus-providers/src/common.rs index 51762cd..5cd293d 100644 --- a/crates/focus-providers/src/common.rs +++ b/crates/focus-providers/src/common.rs @@ -1,3 +1,5 @@ +//! provider 实现的共享辅助:请求配置、用于测试的 mock transport, +//! 以及从配置组装 model-id 的辅助函数。 //! Shared helpers for provider implementations: request config, a mock //! transport for tests, and helpers to assemble the model-id from config. @@ -6,17 +8,30 @@ use focus_transport::{HttpRequest, HttpResponse, Transport}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; +/// provider 配置:API key、model id、base URL。 /// Configuration for a provider: API key, model id, base URL. #[derive(Debug, Clone)] pub struct ProviderConfig { + /// API 密钥(或待解析的 `$VAR`/`${VAR}` 模板)。 + /// API key (or a `$VAR`/`${VAR}` template to be resolved). pub api_key: String, + /// 模型 id,例如 `claude-sonnet-4-5`、`glm-5.2`。 + /// Model id, e.g. `claude-sonnet-4-5`, `glm-5.2`. pub model: String, + /// 仅主机名(不含 scheme、不含 path),例如 `api.anthropic.com`。 + /// Host only (no scheme, no path), e.g. `api.anthropic.com`. pub base_url: String, + /// 默认最大输出 token 数。 + /// Default max output tokens. pub max_tokens: u64, + /// 请求超时(毫秒)。 + /// Request timeout in milliseconds. pub timeout_ms: u64, } impl ProviderConfig { + /// Anthropic 默认配置(`api.anthropic.com`,4096 tokens,120s 超时)。 + /// Default Anthropic config (`api.anthropic.com`, 4096 tokens, 120s timeout). pub fn anthropic(api_key: impl Into, model: impl Into) -> Self { Self { api_key: api_key.into(), @@ -27,6 +42,8 @@ impl ProviderConfig { } } + /// OpenAI 默认配置(`api.openai.com`,4096 tokens,120s 超时)。 + /// Default OpenAI config (`api.openai.com`, 4096 tokens, 120s timeout). pub fn openai(api_key: impl Into, model: impl Into) -> Self { Self { api_key: api_key.into(), @@ -37,6 +54,8 @@ impl ProviderConfig { } } + /// 为任意 OpenAI 兼容厂商构造一份通用配置。`base_url` 仅为主机名 + /// (不含 scheme、不含 path)。 /// Build a generic config for an OpenAI-compatible vendor. `base_url` is /// the host only (no scheme, no path). pub fn new( @@ -53,25 +72,33 @@ impl ProviderConfig { } } + /// Z.ai / 智谱 BigModel 配置(国内端点)。 /// Config for Z.ai / 智谱 BigModel (国内端点). pub fn zai_cn(api_key: impl Into, model: impl Into) -> Self { Self::new(api_key, model, "open.bigmodel.cn") } + /// Z.ai 配置(国际端点)。 /// Config for Z.ai (国际端点). pub fn zai_intl(api_key: impl Into, model: impl Into) -> Self { Self::new(api_key, model, "api.z.ai") } } +/// 一个返回预置 HTTP 响应的 mock transport。用于在不触网的情况下对 +/// provider 做单元测试。 /// A mock transport that returns a canned HTTP response. Used to unit-test /// providers without touching the network. #[derive(Debug, Clone)] pub struct MockTransport { + /// 预置响应;每次请求取一份克隆(取空后再请求会报错)。 + /// Canned response; each request clones it (requests after it's drained error). response: Arc>>, } impl MockTransport { + /// 用给定状态码与 SSE 文本体构造一个预置响应。 + /// Build a canned response with the given status code and SSE body. pub fn new(status: u16, body: impl Into) -> Self { let body = body.into(); let mut headers = HashMap::new(); @@ -108,5 +135,6 @@ impl Transport for MockTransport { } } +/// 暂时不做任何剥离——这是为未来共享转换预留的挂载点。 /// Strip and ignore nothing for now — a place to hang shared transformations. pub fn _unused_keep_request_in_scope(_req: &ProviderRequest) {} diff --git a/crates/focus-providers/src/config_value.rs b/crates/focus-providers/src/config_value.rs index 4b4a8f9..d27b7b1 100644 --- a/crates/focus-providers/src/config_value.rs +++ b/crates/focus-providers/src/config_value.rs @@ -1,17 +1,25 @@ +//! 解析配置值模板,对应 pi 的 `resolveConfigValue`。 //! Resolve config value templates, mirroring pi's `resolveConfigValue`. //! +//! 存储值支持以下形式: //! Supported forms for a stored value: +//! - 纯字面量:`"abc"` → `"abc"` //! - Plain literal: `"abc"` → `"abc"` +//! - 环境变量引用:`"$VAR"` 或 `"${VAR}"` → 环境变量 `VAR` 的值 //! - Env var reference: `"$VAR"` or `"${VAR}"` → the value of env var `VAR` +//! - 字面美元符:`"$$"` → `"$"`、`"$!"` → `"!"` //! - Literal dollar: `"$$"` → `"$"`, `"$!"` → `"!"` //! +//! shell 命令形式(`"!cmd ..."`)暂时有意省略,以缩小信任边界;后续可在某个 +//! feature 之后再加入。 //! The shell-command form (`"!cmd ..."`) is intentionally omitted for now to //! keep the trust boundary small; it can be added later behind a feature. use std::env; -/// Resolve a config value. Returns `None` if the value is `None`; returns an -/// error message in `Err` if an env-var reference names an unset variable. +/// 解析一个配置值:若环境变量引用指向未设置的变量,则在 `Err` 中返回错误信息。 +/// Resolve a config value: returns an error message in `Err` if an env-var +/// reference names an unset variable. pub fn resolve(value: &str) -> Result { let mut out = String::with_capacity(value.len()); let bytes = value.as_bytes(); @@ -19,6 +27,7 @@ pub fn resolve(value: &str) -> Result { while i < bytes.len() { let b = bytes[i]; if b == b'$' { + // 转义处理。 // Escapes. if i + 1 < bytes.len() { let next = bytes[i + 1]; @@ -33,6 +42,7 @@ pub fn resolve(value: &str) -> Result { continue; } } + // ${VAR} 形式。 // ${VAR} form. if i + 1 < bytes.len() && bytes[i + 1] == b'{' { if let Some(close) = bytes[i + 2..].iter().position(|&c| c == b'}') { @@ -46,6 +56,7 @@ pub fn resolve(value: &str) -> Result { } return Err("unterminated '${' in config value".into()); } + // $VAR 形式:对 [A-Z_][A-Z0-9_]* 做贪心匹配。 // $VAR form: greedy match of [A-Z_][A-Z0-9_]*. let mut j = i + 1; while j < bytes.len() { @@ -64,10 +75,12 @@ pub fn resolve(value: &str) -> Result { i = j; continue; } + // 后面未跟名字的单独 '$':按字面输出。 // Lone '$' with no following name: emit literally. out.push('$'); i += 1; } else { + // 拷贝一个 UTF-8 字符。 // Copy one UTF-8 char. let ch_len = utf8_char_len(b); out.push_str(std::str::from_utf8(&bytes[i..i + ch_len]).unwrap_or("")); @@ -77,12 +90,15 @@ pub fn resolve(value: &str) -> Result { Ok(out) } +/// 根据 UTF-8 引导字节返回该字符占用的字节数。 +/// Return the byte length of a UTF-8 char given its lead byte. fn utf8_char_len(b: u8) -> usize { match b { 0x00..=0x7F => 1, 0xC0..=0xDF => 2, 0xE0..=0xEF => 3, 0xF0..=0xF7 => 4, + // 非法引导字节;按 1 处理以继续推进。 _ => 1, // invalid lead byte; treat as 1 to make progress } } diff --git a/crates/focus-providers/src/lib.rs b/crates/focus-providers/src/lib.rs index bd2b7c3..e752378 100644 --- a/crates/focus-providers/src/lib.rs +++ b/crates/focus-providers/src/lib.rs @@ -1,8 +1,13 @@ +//! focus-providers:具体的 LLM provider 实现。 //! focus-providers: concrete LLM provider implementations. //! +//! 每个 provider 都通过将厂商特定的 HTTP/SSE 格式翻译为统一的 +//! [`StreamEvent`] 协议,从而实现 [`focus_core::StreamProvider`]。 //! Each provider implements [`focus_core::StreamProvider`] by translating its //! vendor-specific HTTP/SSE format into the unified [`StreamEvent`] protocol. //! +//! 主要目标是 [`ZaiProvider`](智谱 GLM 编程模型,国内端点),它构建在共享的 +//! [`OpenAiCompatProvider`] 引擎之上。此外也提供 Anthropic 与原生 OpenAI provider。 //! The primary target is [`ZaiProvider`] (智谱 GLM coding models, domestic //! endpoint), built on the shared [`OpenAiCompatProvider`] engine. //! Anthropic and raw-OpenAI providers are also available. @@ -19,4 +24,4 @@ pub use anthropic::AnthropicProvider; pub use common::{MockTransport, ProviderConfig}; pub use config_value::resolve as resolve_config_value; pub use openai_compat::OpenAiCompatProvider; -pub use zai::ZaiProvider; +pub use zai::{ZaiProvider, ZAI_CODING_PATH}; diff --git a/crates/focus-providers/src/openai_compat.rs b/crates/focus-providers/src/openai_compat.rs index 6118a21..deb59ab 100644 --- a/crates/focus-providers/src/openai_compat.rs +++ b/crates/focus-providers/src/openai_compat.rs @@ -1,5 +1,9 @@ +//! 一个可复用的 OpenAI 兼容 provider。 //! A reusable OpenAI-compatible provider. //! +//! 许多厂商(Z.ai/智谱、DeepSeek、Moonshot、Together 等)对外暴露的 API 与 +//! OpenAI 的 Chat Completions 流式格式逐字节兼容,仅在 base URL 与鉴权头上不同。 +//! 本模块把这部分公共逻辑抽出来,让具体 provider 只做薄封装。 //! Many vendors (Z.ai/智谱, DeepSeek, Moonshot, Together, …) expose an API //! that is byte-for-byte compatible with OpenAI's Chat Completions streaming //! format, differing only in the base URL and auth header. This module factors @@ -18,17 +22,28 @@ use std::collections::VecDeque; use std::fmt; use std::sync::Arc; +/// 一个 OpenAI 兼容 provider,由 base URL、api key、model 以及(可选的)用于 +/// 测试的自定义 transport 配置而成。 /// An OpenAI-compatible provider configured with a base URL, api key, model, /// and (optionally) a custom transport for tests. pub struct OpenAiCompatProvider { + /// provider 配置(host、model、key、超时等)。 + /// Provider configuration (host, model, key, timeouts, …). config: ProviderConfig, + /// 实际执行 HTTP 请求的 transport(生产用 `HttpTransport`,测试用 `MockTransport`)。 + /// Transport that performs the HTTP request (`HttpTransport` in production, + /// `MockTransport` in tests). transport: Arc, + /// base host 之下的 chat-completions 路径。默认为 `/v1/chat/completions` + /// (OpenAI),但 Z.ai 使用 `/api/paas/v4/chat/completions`。 /// The chat-completions path under the base host. Defaults to /// `/v1/chat/completions` (OpenAI) but Z.ai uses `/api/paas/v4/chat/completions`. path: String, } impl OpenAiCompatProvider { + /// 使用默认路径 `/v1/chat/completions` 与真实 HTTP transport 构造。 + /// Build with the default path `/v1/chat/completions` and a real HTTP transport. pub fn new(config: ProviderConfig) -> Self { let path = "/v1/chat/completions".to_string(); Self { @@ -38,6 +53,8 @@ impl OpenAiCompatProvider { } } + /// 使用自定义 chat-completions 路径构造(例如 Z.ai 的 + /// `/api/paas/v4/chat/completions`)。 /// Build with a custom chat-completions path (e.g. Z.ai's /// `/api/paas/v4/chat/completions`). pub fn with_path(config: ProviderConfig, path: impl Into) -> Self { @@ -48,6 +65,7 @@ impl OpenAiCompatProvider { } } + /// 使用自定义 base path 与 transport 构造。 /// Build with a custom base path and transport. pub fn with_path_and_transport( config: ProviderConfig, @@ -61,6 +79,7 @@ impl OpenAiCompatProvider { } } + /// 测试辅助:用返回预置 SSE 文本的 mock transport 构造。 /// Test helper: build with a mock transport returning canned SSE text. pub fn with_mock( config: ProviderConfig, @@ -70,16 +89,22 @@ impl OpenAiCompatProvider { Self::with_path_and_transport(config, path, Arc::new(MockTransport::new(200, sse_body))) } + /// 只读访问 provider 配置(host、model、key 等)。 /// Read-only access to the provider config (host, model, key, …). pub fn config_ref(&self) -> &ProviderConfig { &self.config } + /// 只读访问请求路径。 /// Read-only access to the request path. pub fn path(&self) -> &str { &self.path } + /// 将统一的 [`ProviderRequest`] 组装为 OpenAI Chat Completions 的 JSON 请求体 + /// 与 HTTP 请求(含 system 消息、工具定义、采样参数)。 + /// Assemble the unified [`ProviderRequest`] into an OpenAI Chat Completions + /// JSON body and HTTP request (system message, tool definitions, sampling params). fn build_request(&self, request: &ProviderRequest) -> focus_transport::HttpRequest { let mut body = JsonValue::obj(); body.insert("model", self.config.model.clone().into()).ok(); @@ -134,6 +159,11 @@ impl StreamProvider for OpenAiCompatProvider { } } +/// 在一个临时的单线程 tokio runtime 上同步阻塞地执行一次 HTTP 请求。 +/// core 的 [`StreamIterator`] 是同步拉取模型,因此这里把异步 transport 转为同步。 +/// Run one HTTP request synchronously by blocking on a throwaway single-thread +/// tokio runtime. The core's [`StreamIterator`] is a sync pull model, so we +/// bridge the async transport into a sync call here. fn block_on_request( transport: &Arc, req: &focus_transport::HttpRequest, @@ -146,8 +176,11 @@ fn block_on_request( .map_err(|e| CoreError::Provider(e.to_string())) } +// ---- 消息/工具转换(所有 OpenAI 兼容厂商共用)---- // ---- message / tool conversion (shared by all OpenAI-compat vendors) ------- +/// 将统一 [`Message`] 转为 OpenAI 的消息对象(user/assistant/tool)。 +/// Convert a unified [`Message`] into an OpenAI message object (user/assistant/tool). fn convert_message(m: &Message) -> Option { match m { Message::User(u) => { @@ -190,6 +223,9 @@ fn convert_message(m: &Message) -> Option { } } +/// 将统一的工具定义 JSON 转为 OpenAI `tools` 数组(每项包裹为 `type:"function"`)。 +/// Convert the unified tools JSON into the OpenAI `tools` array (each entry +/// wrapped as `type:"function"`). fn convert_tools(tools: &JsonValue) -> JsonValue { let arr = match tools { JsonValue::Arr(a) => a, @@ -215,6 +251,9 @@ fn convert_tools(tools: &JsonValue) -> JsonValue { out } +/// 将内容块的文本部分拼接为单个字符串(OpenAI 不接受结构化 content)。 +/// Flatten the text portion of content blocks into a single string (OpenAI +/// does not accept structured content here). fn content_to_text(blocks: &[ContentBlock]) -> JsonValue { let mut s = String::new(); for b in blocks { @@ -228,21 +267,38 @@ fn content_to_text(blocks: &[ContentBlock]) -> JsonValue { JsonValue::Str(s) } +/// 工具数组是否为空(缺失或空数组均视为空)。 +/// Whether the tools value is empty (missing or empty array both count). fn tools_is_empty(tools: &JsonValue) -> bool { matches!(tools, JsonValue::Arr(a) if a.is_empty()) } +// ---- SSE 转 StreamEvent ---- // ---- SSE -> StreamEvent --------------------------------------------------- -/// Drives an OpenAI-compatible streaming body. +/// 驱动一个 OpenAI 兼容的流式响应体,按需产出 [`StreamEvent`]。 +/// Drives an OpenAI-compatible streaming body, producing [`StreamEvent`]s on demand. pub struct OpenAiCompatStream { + /// 增量 SSE 解析器,从已喂入的响应体中逐个取出 SSE 事件。 + /// Incremental SSE parser; yields SSE events one at a time from the fed body. parser: focus_transport::SseParser, + /// 把 OpenAI 增量(text delta、tool_call delta)归约为核心事件的状态机。 + /// State machine that reduces OpenAI deltas (text delta, tool_call delta) + /// into core events. reducer: ProviderEventReducer, + /// 是否已收到 `[DONE]` 或终止信号。 + /// Whether `[DONE]` / a terminal signal has been seen. done: bool, + /// 已归约但尚未被 `next_event` 取走的事件缓冲;实现"一次 SSE chunk 产出多个事件"的拉取模型。 + /// Buffer of reduced events not yet drained by `next_event`; this is what lets + /// one SSE chunk yield multiple events under the pull model. pending: VecDeque, } impl OpenAiCompatStream { + /// 用完整的响应体字符串和模型名构造;SSE 一次性喂入解析器(非增量网络读取)。 + /// Build from the full response-body string and model name; the whole SSE body + /// is fed to the parser at once (not an incremental network read). fn new(body: &str, model: &str) -> Self { let mut parser = focus_transport::SseParser::new(); parser.feed_str(body); @@ -254,9 +310,16 @@ impl OpenAiCompatStream { } } + /// 处理单个 SSE `data:` 负载:解析 JSON、提取 `choices[0].delta`,把文本/工具 + /// 调用增量交给 [`ProviderEventReducer`],并把产出的事件全部追加到 `pending`。 + /// Handle a single SSE `data:` payload: parse JSON, pull `choices[0].delta`, + /// feed text/tool-call deltas to the [`ProviderEventReducer`], and push every + /// produced event onto `pending`. fn handle_chunk(&mut self, data: &str) { let mut out = Vec::new(); if data == "[DONE]" { + // OpenAI 的终止标记:归约出剩余事件并发出 Done。 + // OpenAI's terminator: flush remaining events from the reducer and emit Done. self.done = true; out.extend(self.reducer.finalize_events()); if let Some(msg) = self.reducer.finish() { @@ -277,6 +340,9 @@ impl OpenAiCompatStream { Some(c) => c, None => return, }; + // OpenAI 的增量集中在 choices[0].delta:content 是文本片段,tool_calls 是工具增量。 + // OpenAI packs increments into choices[0].delta: content is a text fragment, + // tool_calls carries tool-call deltas. let delta = match choice.get("delta") { Some(d) => d, None => return, @@ -312,6 +378,11 @@ impl OpenAiCompatStream { } impl StreamIterator for OpenAiCompatStream { + /// 拉取下一个事件:优先排空 `pending` 缓冲;缓冲空则从 SSE 解析器再取一个 + /// 事件并交给 `handle_chunk` 归约(可能一次性产生多个事件填入缓冲)。 + /// Pull the next event: first drain `pending`; when empty, pull one more SSE + /// event from the parser and reduce it via `handle_chunk` (which may produce + /// several events into the buffer at once). fn next_event(&mut self) -> Option { loop { if let Some(ev) = self.pending.pop_front() { diff --git a/crates/focus-providers/src/zai.rs b/crates/focus-providers/src/zai.rs index 2626b9c..28d32b3 100644 --- a/crates/focus-providers/src/zai.rs +++ b/crates/focus-providers/src/zai.rs @@ -1,50 +1,113 @@ //! Z.ai / 智谱 BigModel provider. //! -//! `glm-4.6` refers to 智谱 AI's coding-capable GLM models served from -//! the domestic endpoint. The API is OpenAI-compatible, so this is a thin -//! wrapper over [`OpenAiCompatProvider`] that fixes the base URL and path. +//! The API is OpenAI-compatible, so this is a thin wrapper over +//! [`OpenAiCompatProvider`] that fixes the base URL and path. //! -//! - Domestic (国内): `https://open.bigmodel.cn/api/paas/v4/chat/completions` -//! - International: `https://api.z.ai/api/paas/v4/chat/completions` +//! GLM offers two access tiers, each with its own base path on `open.bigmodel.cn`: +//! +//! | Tier | Path | Keys | +//! |------|------|-------| +//! | **Coding Plan** (GLM Coding Plan) | `/api/coding/paas/v4/chat/completions` | `xxxxxxxx.yyyyyyyy` format, coding-only | +//! | **Standard PaaS** (通用 PaaS) | `/api/paas/v4/chat/completions` | regular keys, pay-as-you-go balance | +//! +//! Coding-plan keys report `429 余额不足` on the standard PaaS path because +//! they have no balance there — they must use the coding path. Use +//! [`ZaiProvider::coding_cn`] / [`ZaiProvider::coding_intl`] for coding-plan +//! keys and [`ZaiProvider::domestic`] / [`ZaiProvider::international`] for +//! standard PaaS keys. +//! +//! - Coding (国内): `https://open.bigmodel.cn/api/coding/paas/v4/chat/completions` +//! - Coding (国际): `https://api.z.ai/api/coding/paas/v4/chat/completions` +//! - PaaS (国内): `https://open.bigmodel.cn/api/paas/v4/chat/completions` +//! - PaaS (国际): `https://api.z.ai/api/paas/v4/chat/completions` use crate::common::ProviderConfig; use crate::openai_compat::OpenAiCompatProvider; use focus_core::provider::StreamProvider; use std::fmt; -/// The Z.ai chat-completions path (same on both domestic and international). -const ZAI_PATH: &str = "/api/paas/v4/chat/completions"; +/// GLM Coding Plan 的 chat-completions 路径。`xxxxxxxx.yyyyyyyy` 格式的密钥 +/// (coding 套餐密钥)必须使用此路径;在标准 PaaS 路径上它们会返回 `429 余额不足`。 +/// The GLM Coding Plan chat-completions path. Keys in `xxxxxxxx.yyyyyyyy` +/// format (coding-plan keys) must use this path; on the standard PaaS path +/// they report `429 余额不足`. +pub const ZAI_CODING_PATH: &str = "/api/coding/paas/v4/chat/completions"; +/// 标准 GLM PaaS chat-completions 路径(按量付费密钥)。 +/// The standard GLM PaaS chat-completions path (pay-as-you-go keys). +const ZAI_PAAS_PATH: &str = "/api/paas/v4/chat/completions"; + +/// 一个面向 GLM 编程模型的 Z.ai / 智谱 provider。 /// A Z.ai / 智谱 provider targeting the GLM coding models. pub struct ZaiProvider { + /// 被包装的 OpenAI 兼容 provider,承担实际的请求与流解析。 + /// The wrapped OpenAI-compatible provider doing the real work. inner: OpenAiCompatProvider, } impl ZaiProvider { - /// Connect to the domestic (国内) endpoint `open.bigmodel.cn`. + /// 连接国内标准 PaaS 端点 `open.bigmodel.cn`,路径为 + /// `/api/paas/v4/chat/completions`。普通按量付费密钥请用此项。 + /// `api_key` 可以是已解析的字面量,也可以是 `$VAR`/`${VAR}` 模板—— + /// 需要时请先通过 [`crate::config_value::resolve`] 解析。 + /// Connect to the domestic (国内) standard PaaS endpoint + /// `open.bigmodel.cn` with `/api/paas/v4/chat/completions`. + /// Use this for regular pay-as-you-go keys. /// `api_key` may be a resolved literal or a `$VAR`/`${VAR}` template — /// resolve it first via [`crate::config_value::resolve`] if needed. pub fn domestic(api_key: impl Into, model: impl Into) -> Self { Self::from_config(ProviderConfig::zai_cn(api_key, model)) } - /// Connect to the international endpoint `api.z.ai`. + /// 连接国际标准 PaaS 端点 `api.z.ai`。 + /// Connect to the international standard PaaS endpoint `api.z.ai`. pub fn international(api_key: impl Into, model: impl Into) -> Self { Self::from_config(ProviderConfig::zai_intl(api_key, model)) } - /// Build from an explicit [`ProviderConfig`] (host + model + key). + /// 连接国内 **GLM Coding Plan** 端点 `open.bigmodel.cn`,路径为 + /// `/api/coding/paas/v4/chat/completions`。coding 套餐密钥 + /// (`xxxxxxxx.yyyyyyyy` 格式)请用此项。 + /// Connect to the domestic (国内) **GLM Coding Plan** endpoint + /// `open.bigmodel.cn` with `/api/coding/paas/v4/chat/completions`. + /// Use this for coding-plan keys (`xxxxxxxx.yyyyyyyy` format). + pub fn coding_cn(api_key: impl Into, model: impl Into) -> Self { + Self::from_config_with_path(ProviderConfig::zai_cn(api_key, model), ZAI_CODING_PATH) + } + + /// 连接国际 **GLM Coding Plan** 端点 `api.z.ai`,路径为 + /// `/api/coding/paas/v4/chat/completions`。 + /// Connect to the international **GLM Coding Plan** endpoint `api.z.ai` + /// with `/api/coding/paas/v4/chat/completions`. + pub fn coding_intl(api_key: impl Into, model: impl Into) -> Self { + Self::from_config_with_path(ProviderConfig::zai_intl(api_key, model), ZAI_CODING_PATH) + } + + /// 用显式的 [`ProviderConfig`](host + model + key)构造,使用标准 PaaS 路径 + /// `/api/paas/v4/chat/completions`。 + /// Build from an explicit [`ProviderConfig`] (host + model + key) using the + /// standard PaaS path `/api/paas/v4/chat/completions`. pub fn from_config(config: ProviderConfig) -> Self { - let inner = OpenAiCompatProvider::with_path(config, ZAI_PATH); + Self::from_config_with_path(config, ZAI_PAAS_PATH) + } + + /// 用显式的 [`ProviderConfig`] 与自定义 chat-completions 路径构造 + /// (例如 coding 套餐密钥使用 [`ZAI_CODING_PATH`])。 + /// Build from an explicit [`ProviderConfig`] with a custom chat-completions + /// path (e.g. [`ZAI_CODING_PATH`] for coding-plan keys). + pub fn from_config_with_path(config: ProviderConfig, path: &str) -> Self { + let inner = OpenAiCompatProvider::with_path(config, path); Self { inner } } - /// Build with a custom transport (for tests). + /// 用自定义 transport 构造(用于测试)。使用标准 PaaS 路径。 + /// Build with a custom transport (for tests). Uses the standard PaaS path. pub fn with_mock(config: ProviderConfig, sse_body: impl Into) -> Self { - let inner = OpenAiCompatProvider::with_mock(config, ZAI_PATH, sse_body); + let inner = OpenAiCompatProvider::with_mock(config, ZAI_PAAS_PATH, sse_body); Self { inner } } + /// 暴露底层的 OpenAI 兼容 provider。 /// Expose the underlying OpenAI-compatible provider. pub fn inner(&self) -> &OpenAiCompatProvider { &self.inner @@ -77,17 +140,34 @@ mod tests { use focus_json::JsonValue; #[test] - fn domestic_uses_correct_host_and_path() { - let p = ZaiProvider::domestic("key", "glm-4.6"); + fn domestic_uses_correct_host_and_paas_path() { + let p = ZaiProvider::domestic("key", "glm-5.2"); assert_eq!(p.inner.config_ref().base_url, "open.bigmodel.cn"); - assert_eq!(p.inner.config_ref().model, "glm-4.6"); + assert_eq!(p.inner.config_ref().model, "glm-5.2"); assert_eq!(p.inner.path(), "/api/paas/v4/chat/completions"); } #[test] fn international_uses_correct_host() { - let p = ZaiProvider::international("key", "glm-4.6"); + let p = ZaiProvider::international("key", "glm-5.2"); assert_eq!(p.inner.config_ref().base_url, "api.z.ai"); + assert_eq!(p.inner.path(), "/api/paas/v4/chat/completions"); + } + + #[test] + fn coding_cn_uses_coding_path() { + // Coding 套餐密钥必须命中 /api/coding/paas/v4,否则会 429 余额不足。 + // Coding-plan keys must hit /api/coding/paas/v4 — otherwise 429 余额不足. + let p = ZaiProvider::coding_cn("xxxxxxxx.yyyyyyyy", "glm-5.2"); + assert_eq!(p.inner.config_ref().base_url, "open.bigmodel.cn"); + assert_eq!(p.inner.path(), "/api/coding/paas/v4/chat/completions"); + } + + #[test] + fn coding_intl_uses_coding_path() { + let p = ZaiProvider::coding_intl("xxxxxxxx.yyyyyyyy", "glm-5.2"); + assert_eq!(p.inner.config_ref().base_url, "api.z.ai"); + assert_eq!(p.inner.path(), "/api/coding/paas/v4/chat/completions"); } #[test] @@ -96,9 +176,9 @@ mod tests { data: {\"choices\":[{\"delta\":{\"content\":\",世界\"}}]}\n\n\ data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\ data: [DONE]\n\n"; - let provider = ZaiProvider::with_mock(ProviderConfig::zai_cn("test", "glm-4.6"), sse); + let provider = ZaiProvider::with_mock(ProviderConfig::zai_cn("test", "glm-5.2"), sse); let req = ProviderRequest { - model: "glm-4.6".into(), + model: "glm-5.2".into(), system_prompt: "".into(), messages: vec![Message::user_text("你好")], tools: JsonValue::arr(), diff --git a/crates/focus-tools/src/lib.rs b/crates/focus-tools/src/lib.rs index cca5a4c..0d6161f 100644 --- a/crates/focus-tools/src/lib.rs +++ b/crates/focus-tools/src/lib.rs @@ -1,6 +1,10 @@ +//! focus-tools:内置的文件与 shell 工具集。 //! focus-tools: built-in file and shell tools. //! +//! 每个工具都实现 [`focus_core::Tool`]:`read`、`write`、`edit`、`bash`。 //! Each tool implements [`focus_core::Tool`]: `read`, `write`, `edit`, `bash`. +//! +//! 本里程碑阶段尚未实现。 //! Not yet implemented in this milestone. #![forbid(unsafe_code)] diff --git a/crates/focus-transport/src/error.rs b/crates/focus-transport/src/error.rs index 6285ce8..3f10697 100644 --- a/crates/focus-transport/src/error.rs +++ b/crates/focus-transport/src/error.rs @@ -1,19 +1,28 @@ +//! 传输层的错误类型。 //! Error type for the transport layer. use std::fmt; +/// 传输层可能产生的错误。 +/// Errors that can arise in the transport layer. #[derive(Debug)] pub enum TransportError { + /// URL 格式非法或不被支持。 /// The URL was malformed or unsupported. BadUrl(String), + /// DNS 解析失败。 /// DNS resolution failed. Dns(String), + /// 网络 I/O 错误(TCP、TLS 或 HTTP)。 /// A network I/O error (TCP, TLS, or HTTP). Io(String), + /// TLS 握手失败。 /// The TLS handshake failed. Tls(String), + /// 服务器返回非 2xx 状态码或格式错误的响应。 /// The server returned a non-2xx status or a malformed response. Http { status: u16, body: String }, + /// 请求超时。 /// A timeout elapsed. Timeout, } @@ -35,10 +44,14 @@ impl fmt::Display for TransportError { impl std::error::Error for TransportError {} +/// 将 std::io::Error 转换为传输层错误(归入 Io 变体)。 +/// Convert a std::io::Error into a transport error (as the Io variant). impl From for TransportError { fn from(e: std::io::Error) -> Self { TransportError::Io(e.to_string()) } } +/// 传输层操作的统一返回类型别名。 +/// Standard Result alias for transport operations. pub type TransportResult = Result; diff --git a/crates/focus-transport/src/http.rs b/crates/focus-transport/src/http.rs index 703ab58..d8ae6a0 100644 --- a/crates/focus-transport/src/http.rs +++ b/crates/focus-transport/src/http.rs @@ -1,3 +1,5 @@ +//! HTTP/1.1 请求/响应类型,以及最小化的请求序列化器与响应解析器。 +//! 我们只实现足以 POST JSON 并读取流式(分块或 content-length)响应体的 HTTP/1.1 子集。 //! HTTP/1.1 request/response types and a minimal request serializer + response //! parser. We implement just enough of HTTP/1.1 to POST JSON and read a //! streamed (chunked or content-length) body. @@ -5,6 +7,8 @@ use crate::error::{TransportError, TransportResult}; use std::collections::HashMap; +/// HTTP 请求方法(仅支持 GET/POST 子集)。 +/// HTTP request method (a GET/POST subset only). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Method { Get, @@ -12,6 +16,8 @@ pub enum Method { } impl Method { + /// 返回该方法在线路上使用的字符串形式。 + /// Return the wire-format string for this method. pub fn as_str(&self) -> &'static str { match self { Method::Get => "GET", @@ -20,17 +26,30 @@ impl Method { } } +/// 一个待发送到线路上的 HTTP 请求。 /// An HTTP request to be sent over the wire. #[derive(Debug, Clone)] pub struct HttpRequest { + /// 请求方法。 + /// The request method. pub method: Method, + /// 请求目标路径(含查询串)。 + /// The request target path (including query string). pub path: String, + /// 目标主机名。 + /// The target host. pub host: String, + /// 请求头(按插入顺序保留)。 + /// Request headers, in insertion order. pub headers: Vec<(String, String)>, + /// 请求体字节。 + /// The request body bytes. pub body: Vec, } impl HttpRequest { + /// 以 POST 方法构造一个到 `host` 的 `path` 请求(builder 入口)。 + /// Begin a POST request to `path` on `host` (builder entry point). pub fn post(host: &str, path: &str) -> Self { Self { method: Method::Post, @@ -41,11 +60,15 @@ impl HttpRequest { } } + /// 追加一个请求头,返回自身以支持链式调用。 + /// Append a header and return self for chaining. pub fn header(mut self, name: &str, value: &str) -> Self { self.headers.push((name.to_string(), value.to_string())); self } + /// 设置 JSON 请求体,自动添加 Content-Type 与 Content-Length 头。 + /// Set a JSON body, adding Content-Type and Content-Length headers. pub fn json_body(mut self, body: &str) -> Self { self.headers .push(("Content-Type".into(), "application/json".into())); @@ -55,6 +78,7 @@ impl HttpRequest { self } + /// 序列化为 HTTP/1.1 线路格式。 /// Serialize to the HTTP/1.1 wire format. pub fn serialize(&self) -> Vec { let mut out = Vec::new(); @@ -62,6 +86,7 @@ impl HttpRequest { out.push(b' '); out.extend_from_slice(self.path.as_bytes()); out.extend_from_slice(b" HTTP/1.1\r\n"); + // 确保 Host 头存在。 // Ensure Host is present. let has_host = self .headers @@ -79,31 +104,43 @@ impl HttpRequest { } } +/// 已解析的 HTTP 响应。 /// A parsed HTTP response. #[derive(Debug, Clone)] pub struct HttpResponse { + /// HTTP 状态码。 + /// HTTP status code. pub status: u16, + /// 响应头(键已小写化)。 + /// Headers (keys lowercased). pub headers: HashMap, + /// 目前后已收到的响应体字节(非流式读取时)。 /// The body bytes received so far (for non-streaming reads). pub body: Vec, } impl HttpResponse { + /// 状态码为 2xx 时返回 true。 /// True for 2xx status codes. pub fn is_success(&self) -> bool { (200..300).contains(&self.status) } + /// 以有损方式将响应体解码为字符串。 + /// Decode the body to a string (lossy). pub fn body_string(&self) -> String { String::from_utf8_lossy(&self.body).to_string() } } +/// 从 `buf` 起始处解析 HTTP/1.1 状态行与响应头。返回 `(响应元数据, 已消费的头部字节数)`, +/// 其中响应元数据的 body 为空;调用方随后根据 Transfer-Encoding/Content-Length 读取响应体。 /// Parses the HTTP/1.1 status line and headers from the start of `buf`. /// Returns `(response_meta, header_bytes_consumed)` where `response_meta` has /// an empty body. The caller then reads the body based on /// Transfer-Encoding/Content-Length. pub fn parse_response_head(buf: &[u8]) -> TransportResult<(HttpResponse, usize)> { + // 查找响应头结束位置(\r\n\r\n)。 // Find the end of headers (\r\n\r\n). let head_end = find_subsequence(buf, b"\r\n\r\n") .ok_or_else(|| TransportError::Io("incomplete HTTP response head".into()))?; @@ -137,12 +174,15 @@ pub fn parse_response_head(buf: &[u8]) -> TransportResult<(HttpResponse, usize)> headers, body: Vec::new(), }; + // 已消费字节数 = head_end + len("\r\n\r\n")。 // Consumed bytes = head_end + len("\r\n\r\n"). Ok((resp, head_end + 4)) } -/// Determine how many body bytes remain to read. Returns `None` for chunked -/// encoding (caller must use the chunk decoder). +/// 判定还需读取多少响应体字节。分块编码时返回 [`BodyLength::Chunked`] +/// (调用方须使用分块解码器)。 +/// Determine how many body bytes remain to read. Returns [`BodyLength::Chunked`] +/// for chunked encoding (caller must use the chunk decoder). pub fn body_length(resp: &HttpResponse) -> BodyLength { if resp .headers @@ -161,13 +201,23 @@ pub fn body_length(resp: &HttpResponse) -> BodyLength { } } +/// 响应体长度的三种确定方式。 +/// The three ways to determine the response body length. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BodyLength { + /// 固定长度(Content-Length)。 + /// Fixed length (Content-Length). Fixed(usize), + /// 分块传输编码。 + /// Chunked transfer encoding. Chunked, + /// 读至连接关闭。 + /// Read until connection close. UntilClose, } +/// 在 `haystack` 中查找 `needle` 首次出现的字节偏移。 +/// Find the first byte offset of `needle` within `haystack`. fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option { haystack .windows(needle.len()) diff --git a/crates/focus-transport/src/lib.rs b/crates/focus-transport/src/lib.rs index ce0b8a4..64d4890 100644 --- a/crates/focus-transport/src/lib.rs +++ b/crates/focus-transport/src/lib.rs @@ -1,18 +1,40 @@ +//! focus-transport:HTTPS 客户端与 SSE 解析器。 //! focus-transport: HTTPS client and SSE parser. //! +//! 基于 tokio + rustls 构建。提供 [`Transport`] trait、使用 rustls 完成 TLS 握手的 +//! HTTPS 客户端、手写的 HTTP/1.1 编解码器,以及用于 Server-Sent Events 流的 +//! 增量解析器 [`SseParser`]。 //! Built on tokio + rustls. Provides a [`Transport`] trait, an HTTPS client //! that performs TLS handshakes with rustls, a hand-rolled HTTP/1.1 codec, and //! an incremental [`SseParser`] for Server-Sent Events streams. #![forbid(unsafe_code)] +/// 传输层错误类型。 +/// Error types for the transport layer. pub mod error; +/// HTTP/1.1 请求/响应类型与编解码。 +/// HTTP/1.1 request/response types and codec. pub mod http; +/// 增量 SSE(Server-Sent Events)解析器。 +/// Incremental SSE (Server-Sent Events) parser. pub mod sse; +/// 基于 rustls + tokio 的 TLS 连接建立。 +/// TLS connection establishment with rustls + tokio. pub mod tls; +/// [`Transport`] trait 及其 tokio+rustls 实现 [`HttpTransport`]。 +/// The [`Transport`] trait and its tokio+rustls impl [`HttpTransport`]. pub mod transport; +/// 传输层错误与结果类型别名。 +/// Transport error type and result alias. pub use error::{TransportError, TransportResult}; +/// HTTP 请求、响应与方法。 +/// HTTP request, response, and method. pub use http::{HttpRequest, HttpResponse, Method}; +/// SSE 事件与增量解析器。 +/// SSE event and incremental parser. pub use sse::{SseEvent, SseParser}; +/// HTTP 传输实现与传输 trait。 +/// HTTP transport implementation and the transport trait. pub use transport::{HttpTransport, Transport}; diff --git a/crates/focus-transport/src/sse.rs b/crates/focus-transport/src/sse.rs index 6c7241d..a6582c3 100644 --- a/crates/focus-transport/src/sse.rs +++ b/crates/focus-transport/src/sse.rs @@ -1,53 +1,75 @@ +//! 增量 Server-Sent Events(SSE)解析器。 //! Incremental Server-Sent Events (SSE) parser. //! +//! SSE 流是面向行的:事件之间以空行分隔;在一个事件内部,以 `event:`、`data:`、 +//! `id:`、`retry:` 开头的行用于设置字段。本解析器是增量的——它按网络到达顺序 +//! 接收字节块,并输出完整的 [`SseEvent`]。 //! SSE streams are line-oriented: events are separated by blank lines, and //! within an event, lines starting with `event:`, `data:`, `id:`, and //! `retry:` set fields. The parser is incremental — it accepts chunks of //! bytes as they arrive from the network and emits complete [`SseEvent`]s. //! +//! 参见 。 //! See . use std::collections::VecDeque; +/// 一个已解析的 SSE 事件。 /// One parsed SSE event. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SseEvent { + /// 事件类型;未设置时按规范默认为 `"message"`。 /// The event type. Defaults to `"message"` per spec if not set. pub event: String, + /// 拼接后的 data 行(以 `\n` 连接)。 /// Concatenated data lines (joined with `\n`). pub data: String, + /// 最近的事件 id(如有)。 /// The last event id, if any. pub id: String, + /// 重连间隔(如已设置)。 /// The reconnection interval, if set. pub retry: Option, } +/// 增量 SSE 解析器。通过 [`SseParser::feed`] 喂入原始(UTF-8)字节, +/// 通过 [`SseParser::next_event`] 取出完整事件。 /// An incremental SSE parser. Feed it raw bytes (UTF-8) via [`SseParser::feed`] -/// and pull complete events out of [`SseParser::next`]. +/// and pull complete events out of [`SseParser::next_event`]. #[derive(Debug, Default)] pub struct SseParser { + /// 已解码但尚未处理的文本。 /// Decoded-but-unprocessed text. buffer: String, + /// buffer 中已扫描行边界的位置。 /// Position up to which `buffer` has been scanned for line boundaries. scanned: usize, + /// 已完成、待取走的事件队列。 /// Completed events waiting to be drained. ready: VecDeque, + /// 正在累积的当前事件。 /// The event currently being accumulated. current: SseEvent, + /// 当前事件是否已设置过 data 字段(即使为空)。 /// Whether the current event has had a `data:` field set (even to empty). current_has_data: bool, + /// 从上一字节块遗留的部分 UTF-8 序列。 /// Bytes of a partial UTF-8 sequence carried over from the last chunk. partial_utf8: Vec, } impl SseParser { + /// 创建一个空的解析器。 + /// Create an empty parser. pub fn new() -> Self { Self::default() } + /// 喂入一块字节。边界处不完整的 UTF-8 序列会被缓冲,直到下一块将其补全。 /// Feed a chunk of bytes. Partial UTF-8 sequences at the boundary are /// buffered until the next chunk completes them. pub fn feed(&mut self, bytes: &[u8]) { + // 合并遗留字节与新数据块,再解码。 // Combine any leftover bytes with the new chunk, then decode. let mut combined = std::mem::take(&mut self.partial_utf8); combined.extend_from_slice(bytes); @@ -59,9 +81,11 @@ impl SseParser { Err(e) => { let valid = e.valid_up_to(); if valid == 0 { + // 整块都是不完整序列——保留它。 // The entire chunk is an incomplete sequence — keep it. self.partial_utf8 = combined; } else { + // 解码有效前缀,其余部分保留。 // Decode the valid prefix and keep the rest. let prefix = std::str::from_utf8(&combined[..valid]) .unwrap_or("") @@ -74,21 +98,26 @@ impl SseParser { } } + /// 直接喂入已解码文本(便于测试)。 /// Feed already-decoded text directly (useful for tests). pub fn feed_str(&mut self, text: &str) { self.buffer.push_str(text); self.scan(); } + /// 取出下一个完整事件(如有)。 /// Pull the next complete event, if any. pub fn next_event(&mut self) -> Option { self.ready.pop_front() } + /// 扫描 buffer 中的换行,将每一行交给 process_line 处理。 + /// Scan buffer for newlines and dispatch each line to process_line. fn scan(&mut self) { while let Some(nl) = self.buffer[self.scanned..].find('\n') { let line_start = self.scanned; let line_end = line_start + nl; + // 去掉行尾 '\r'(CRLF 行结束符)。 // Drop a trailing '\r' (CRLF line endings). let raw_line_end = if line_end > 0 && self.buffer.as_bytes()[line_end - 1] == b'\r' { line_end - 1 @@ -99,6 +128,7 @@ impl SseParser { self.scanned = line_end + 1; self.process_line(&line); } + // 回收已消费的缓冲区空间。 // Reclaim consumed buffer space. if self.scanned > 0 && self.scanned == self.buffer.len() { self.buffer.clear(); @@ -107,8 +137,10 @@ impl SseParser { } fn process_line(&mut self, line: &str) { + // 空行触发当前事件的派发。 // A blank line dispatches the current event. if line.is_empty() { + // 按规范,只有设置了 data 字段(即使为空)或显式事件类型时才派发该事件。 // Per spec, an event is dispatched if it had a data field set // (even to empty) or an explicit event type. if self.current_has_data || !self.current.event.is_empty() { @@ -121,6 +153,7 @@ impl SseParser { } return; } + // 以 ':' 开头的行是注释——忽略。 // Lines starting with ':' are comments — ignore. if line.starts_with(':') { return; @@ -128,6 +161,7 @@ impl SseParser { let (field, value) = match line.find(':') { Some(idx) => { let field = &line[..idx]; + // 跳过冒号后的单个前导空格。 // Skip a single leading space after the colon. let mut value = &line[idx + 1..]; if value.starts_with(' ') { @@ -137,6 +171,8 @@ impl SseParser { } None => (line, ""), }; + // 根据字段名更新当前事件的对应字段。 + // Update the corresponding field of the current event by field name. match field { "event" => self.current.event = value.to_string(), "data" => { diff --git a/crates/focus-transport/src/tls.rs b/crates/focus-transport/src/tls.rs index 1e7601a..fdd1b4b 100644 --- a/crates/focus-transport/src/tls.rs +++ b/crates/focus-transport/src/tls.rs @@ -1,5 +1,8 @@ +//! 使用 rustls + tokio 建立 TLS 连接。 //! TLS connection establishment using rustls + tokio. //! +//! 我们构建一个单一、共享的 [`rustls::ClientConfig`],使用平台原生根证书 +//! (仅在测试中回退为放行式配置)。 //! We build a single shared [`rustls::ClientConfig`] with the platform's native //! root certificates (falling back to a permissive config only in tests). @@ -10,13 +13,16 @@ use tokio::net::TcpStream; use tokio_rustls::client::TlsStream; use tokio_rustls::TlsConnector; +/// 到远端主机的已连接 TLS 流。 /// A connected TLS stream to a remote host. pub type TlsConnection = TlsStream; +/// 获取共享的、延迟初始化的 rustls 客户端配置。 /// Get the shared, lazily-initialized rustls client config. pub fn client_config() -> &'static ClientConfig { static CONFIG: OnceLock = OnceLock::new(); CONFIG.get_or_init(|| { + // 加载操作系统原生的根证书库。 // Load the OS-native root certificate store. let mut roots = rustls::RootCertStore::empty(); match rustls_native_certs::load_native_certs() { @@ -35,6 +41,8 @@ pub fn client_config() -> &'static ClientConfig { }) } +/// 仅用于测试、接受任意服务器证书的配置。该配置绝不用于生产构建, +/// 仅为单元测试连接桩服务器而存在。 /// A test-only config that accepts any server certificate. This is NEVER used /// in production builds; it exists so unit tests can talk to stub servers. #[cfg(test)] @@ -45,10 +53,14 @@ pub fn danger_no_verification_config() -> ClientConfig { .with_no_client_auth() } +/// 一个总是通过验证的证书校验器(仅测试用)。 +/// A certificate verifier that always succeeds (test only). #[cfg(test)] #[derive(Debug)] struct NoVerification; +/// 将所有证书校验视为通过。 +/// Treat every certificate verification as passing. #[cfg(test)] impl rustls::client::danger::ServerCertVerifier for NoVerification { fn verify_server_cert( @@ -95,6 +107,7 @@ impl rustls::client::danger::ServerCertVerifier for NoVerification { } } +/// 连接到 `host:port` 的 TLS 流。 /// Connect a TLS stream to `host:port`. pub async fn connect(host: &str, port: u16) -> TransportResult { let addr = format!("{}:{}", host, port); diff --git a/crates/focus-transport/src/transport.rs b/crates/focus-transport/src/transport.rs index f9d23c5..c9d9669 100644 --- a/crates/focus-transport/src/transport.rs +++ b/crates/focus-transport/src/transport.rs @@ -1,5 +1,9 @@ +//! [`Transport`] trait 及基于 tokio+rustls 的 [`HttpTransport`] 实现。 //! The [`Transport`] trait and a tokio+rustls-based [`HttpTransport`]. //! +//! 传输层发送一个 [`HttpRequest`] 并返回 [`HttpResponse`]。具体的 [`HttpTransport`] +//! 为每个请求建立一次 TLS 连接,并将完整响应体(分块或 content-length)读入内存。 +//! 这足以支撑 `focus-core` 所采用的同步拉取式流模型。 //! A transport sends an [`HttpRequest`] and returns the [`HttpResponse`]. The //! concrete [`HttpTransport`] performs a TLS connection per request and reads //! the full body (chunked or content-length) into memory. This is sufficient @@ -10,10 +14,13 @@ use crate::http::{body_length, parse_response_head, BodyLength, HttpRequest, Htt use crate::tls; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +/// 通过网络连接发送 HTTP 请求。 /// Sends HTTP requests over a network connection. pub trait Transport: Send + Sync { + /// 发送请求并等待完整响应。 /// Send the request and await the full response. /// + /// 实现者在 future 生命周期内借用请求;调用方须使其存活至 future 完成。 /// Implementations borrow the request for the duration of the future; /// callers must keep it alive until the future resolves. fn request<'a>( @@ -24,11 +31,14 @@ pub trait Transport: Send + Sync { >; } +/// 基于 tokio + rustls 的真实 HTTPS 传输实现。 /// A real HTTPS transport backed by tokio + rustls. #[derive(Debug, Default, Clone)] pub struct HttpTransport; impl HttpTransport { + /// 创建默认实例。 + /// Create a default instance. pub fn new() -> Self { Self } @@ -45,6 +55,8 @@ impl Transport for HttpTransport { } } +/// 执行单次 HTTP 请求:建立 TLS 连接、序列化并发送请求、读取完整响应并解析。 +/// Perform a single HTTP request: open TLS, serialize & send, read the full response, parse. async fn perform_request(req: &HttpRequest) -> TransportResult { let mut stream = tls::connect(&req.host, 443).await?; @@ -58,14 +70,19 @@ async fn perform_request(req: &HttpRequest) -> TransportResult { .await .map_err(|e| TransportError::Io(format!("flush request: {}", e)))?; + // 将整个响应读入缓冲区,再解析。 // Read the entire response into a buffer, then parse. let mut buf = Vec::with_capacity(8192); loop { let mut chunk = [0u8; 4096]; match stream.read(&mut chunk).await { - Ok(0) => break, // clean EOF + Ok(0) => break, // 干净的 EOF / clean EOF Ok(n) => buf.extend_from_slice(&chunk[..n]), Err(e) => { + // rustls 会把对端在未发送 TLS close_notify 的情况下关闭连接表现为 + // UnexpectedEof 错误(某些代理会直接重置连接 → ConnectionReset)。 + // 若此时缓冲区已有响应数据,则这些属于良性的连接拆除——视作正常 EOF。 + // 其他错误,或在收到任何数据之前就发生的错误,则向上传播。 // rustls surfaces a peer that closed the connection without a // TLS close_notify as an UnexpectedEof error (and some proxies // reset the connection → ConnectionReset). If we already have @@ -114,29 +131,33 @@ async fn perform_request(req: &HttpRequest) -> TransportResult { Ok(resp) } +/// 解码 HTTP/1.1 分块传输编码(chunked)的响应体。 /// Decode an HTTP/1.1 chunked transfer-encoded body. fn decode_chunked(bytes: &[u8]) -> TransportResult> { let mut out = Vec::new(); let mut pos = 0; while pos < bytes.len() { + // 读取分块大小行。 // Read the chunk size line. let line_end = find_line_end(&bytes[pos..])?; let line = std::str::from_utf8(&bytes[pos..pos + line_end]) .map_err(|_| TransportError::Io("non-utf8 chunk size line".into()))?; let size_str = line.trim_end_matches(['\r', '\n']); + // 去掉分块扩展(";...")。 // Strip any chunk extensions (";..."). let size_hex = size_str.split(';').next().unwrap_or("0").trim(); let size = usize::from_str_radix(size_hex, 16) .map_err(|_| TransportError::Io(format!("invalid chunk size: {}", size_hex)))?; - pos += line_end + 1; // skip the line + '\n' + pos += line_end + 1; // 跳过该行及 '\n' / skip the line + '\n' if size == 0 { - break; // last chunk + break; // 最后一个分块 / last chunk } if pos + size > bytes.len() { return Err(TransportError::Io("chunk body truncated".into())); } out.extend_from_slice(&bytes[pos..pos + size]); pos += size; + // 跳过分块数据后的尾随 CRLF。 // Skip the trailing CRLF after the chunk data. if pos + 1 < bytes.len() && bytes[pos] == b'\r' && bytes[pos + 1] == b'\n' { pos += 2; @@ -147,6 +168,8 @@ fn decode_chunked(bytes: &[u8]) -> TransportResult> { Ok(out) } +/// 返回 `bytes` 中第一个 '\n' 的字节偏移;不存在则报错。 +/// Return the byte offset of the first '\n' in `bytes`, or an error if none. fn find_line_end(bytes: &[u8]) -> TransportResult { bytes .iter() @@ -160,6 +183,7 @@ mod tests { #[test] fn decodes_simple_chunked_body() { + // 两个分块:"Hello" 与 " World",随后是终止分块。 // Two chunks: "Hello" and " World", then terminating chunk. let body = b"5\r\nHello\r\n6\r\n World\r\n0\r\n\r\n"; let decoded = decode_chunked(body).unwrap();