feat: 添加 Web 前端及服务端 SSE 流式支持,扩展多模型兼容
后端:
- server: 实现完整的 HTTP 会话管理(CRUD)+ SSE 事件流推送,
支持双通道架构(POST 发消息 + GET SSE 接收流式响应)
- runtime: ContentBlock 新增 Thinking / RedactedThinking 变体,
支持思考过程和已编辑思考的序列化/反序列化
- api: 注册 GLM 系列模型(glm-4/5 等)到模型注册表,
扩展 XAI/OpenAI 兼容提供商的请求构建逻辑
前端:
- 基于 Ant Design X 构建完整聊天界面:Bubble.List 消息列表、
Sender 输入框、Conversations 会话管理、Think 思考过程折叠、
ThoughtChain 工具调用链展示
- XMarkdown 集成:代码高亮、Mermaid 图表、LaTeX 公式、
自定义脚注、流式渲染(incomplete 占位符)
- SSE Hook 对接服务端事件流,手动管理 AssistantBuffer 累积 delta
- 深色/浅色主题切换,会话侧边栏(新建/切换/删除)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# API 模块 (api)
|
||||
|
||||
本模块提供了与大型语言模型 (LLM) 服务提供商(主要是 Anthropic 的 Claude 和兼容 OpenAI 的服务)进行交互的高层抽象和客户端。
|
||||
|
||||
## 概览
|
||||
|
||||
`api` 模块负责以下职责:
|
||||
- 标准化与不同 AI 提供商的通信。
|
||||
- 通过服务器发送事件 (SSE) 处理流式响应。
|
||||
- 管理身份验证源(API 密钥、OAuth 令牌)。
|
||||
- 提供消息、工具和使用情况跟踪的共享数据结构。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **提供商抽象 (Provider Abstraction)**:支持多种 AI 后端,包括:
|
||||
- `ClawApiClient`: Claude 模型的主要提供商。
|
||||
- `OpenAiCompatClient`: 支持兼容 OpenAI 的 API(如本地模型、专门的提供商)。
|
||||
- **流式支持 (Streaming Support)**:健壮的 SSE 解析实现 (`SseParser`),用于处理实时的内容生成。
|
||||
- **工具集成 (Tool Integration)**:为 `ToolDefinition`、`ToolChoice` 和 `ToolResultContentBlock` 提供强类型定义,支持智能代理 (Agentic) 工作流。
|
||||
- **身份验证管理 (Auth Management)**:用于解析启动身份验证源和管理 OAuth 令牌的实用工具。
|
||||
- **模型智能 (Model Intelligence)**:解析模型别名和计算最大标记 (Token) 限制的元数据及辅助函数。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **`client.rs`**: 定义了 `ProviderClient` 特性 (Trait) 和基础客户端逻辑。它使用 `reqwest` 处理 HTTP 请求,并管理消息流的生命周期。
|
||||
- **`types.rs`**: 包含 API 的核心数据模型,如 `InputMessage`、`OutputContentBlock` 以及 `MessageRequest`/`MessageResponse`。
|
||||
- **`sse.rs`**: 实现了一个状态化的 SSE 解析器,能够处理分段的数据块并发出类型化的 `StreamEvent`。
|
||||
- **`providers/`**: 包含针对不同 LLM 端点的特定逻辑,将它们的独特格式映射到本模块使用的共享类型。
|
||||
|
||||
### 数据流
|
||||
|
||||
1. 构建包含模型详情、消息和工具定义的 `MessageRequest`。
|
||||
2. `ApiClient` 将此请求转换为提供商特定的 HTTP 请求。
|
||||
3. 如果启用了流式传输,客户端返回一个 `MessageStream`,该流使用 `SseParser` 来产生 `StreamEvent`。
|
||||
4. 最终响应包含用于跟踪 Token 消耗的 `Usage` 信息。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```rust
|
||||
use api::{ApiClient, MessageRequest, InputMessage};
|
||||
|
||||
// 示例初始化(已简化)
|
||||
let client = ApiClient::new(auth_source);
|
||||
let request = MessageRequest {
|
||||
model: "claude-3-5-sonnet-20241022".to_string(),
|
||||
messages: vec![InputMessage::user("你好,世界!")],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let stream = client.create_message_stream(request).await?;
|
||||
```
|
||||
@@ -634,7 +634,7 @@ struct ApiErrorEnvelope {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiErrorBody {
|
||||
#[serde(rename = "type")]
|
||||
#[serde(alias = "code", rename = "type")]
|
||||
error_type: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
@@ -138,6 +138,69 @@ const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
|
||||
default_base_url: openai_compat::DEFAULT_XAI_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-4-plus",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-4-0520",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-4",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-4-air",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-4-flash",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-5",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
(
|
||||
"glm-5.1",
|
||||
ProviderMetadata {
|
||||
provider: ProviderKind::ClawApi,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
base_url_env: "ANTHROPIC_BASE_URL",
|
||||
default_base_url: claw_provider::DEFAULT_BASE_URL,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -251,7 +251,7 @@ impl MessageStream {
|
||||
}
|
||||
|
||||
if self.done {
|
||||
self.pending.extend(self.state.finish()?);
|
||||
self.pending.extend(self.state.finish());
|
||||
if let Some(event) = self.pending.pop_front() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
@@ -261,7 +261,7 @@ impl MessageStream {
|
||||
match self.response.chunk().await? {
|
||||
Some(chunk) => {
|
||||
for parsed in self.parser.push(&chunk)? {
|
||||
self.pending.extend(self.state.ingest_chunk(parsed)?);
|
||||
self.pending.extend(self.state.ingest_chunk(parsed));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -297,6 +297,7 @@ impl OpenAiSseParser {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
struct StreamState {
|
||||
model: String,
|
||||
message_started: bool,
|
||||
@@ -322,7 +323,7 @@ impl StreamState {
|
||||
}
|
||||
}
|
||||
|
||||
fn ingest_chunk(&mut self, chunk: ChatCompletionChunk) -> Result<Vec<StreamEvent>, ApiError> {
|
||||
fn ingest_chunk(&mut self, chunk: ChatCompletionChunk) -> Vec<StreamEvent> {
|
||||
let mut events = Vec::new();
|
||||
if !self.message_started {
|
||||
self.message_started = true;
|
||||
@@ -377,7 +378,7 @@ impl StreamState {
|
||||
state.apply(tool_call);
|
||||
let block_index = state.block_index();
|
||||
if !state.started {
|
||||
if let Some(start_event) = state.start_event()? {
|
||||
if let Some(start_event) = state.start_event() {
|
||||
state.started = true;
|
||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||
} else {
|
||||
@@ -410,12 +411,12 @@ impl StreamState {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
events
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Vec<StreamEvent>, ApiError> {
|
||||
fn finish(&mut self) -> Vec<StreamEvent> {
|
||||
if self.finished {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
}
|
||||
self.finished = true;
|
||||
|
||||
@@ -429,7 +430,7 @@ impl StreamState {
|
||||
|
||||
for state in self.tool_calls.values_mut() {
|
||||
if !state.started {
|
||||
if let Some(start_event) = state.start_event()? {
|
||||
if let Some(start_event) = state.start_event() {
|
||||
state.started = true;
|
||||
events.push(StreamEvent::ContentBlockStart(start_event));
|
||||
if let Some(delta_event) = state.delta_event() {
|
||||
@@ -464,7 +465,7 @@ impl StreamState {
|
||||
}));
|
||||
events.push(StreamEvent::MessageStop(MessageStopEvent {}));
|
||||
}
|
||||
Ok(events)
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,22 +498,20 @@ impl ToolCallState {
|
||||
self.openai_index + 1
|
||||
}
|
||||
|
||||
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
|
||||
let Some(name) = self.name.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
fn start_event(&self) -> Option<ContentBlockStartEvent> {
|
||||
let name = self.name.clone()?;
|
||||
let id = self
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
|
||||
Ok(Some(ContentBlockStartEvent {
|
||||
Some(ContentBlockStartEvent {
|
||||
index: self.block_index(),
|
||||
content_block: OutputContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input: json!({}),
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
|
||||
@@ -678,6 +677,14 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
|
||||
}
|
||||
})),
|
||||
InputContentBlock::ToolResult { .. } => {}
|
||||
InputContentBlock::Thinking { thinking, .. } => {
|
||||
text.push_str("<thinking>\n");
|
||||
text.push_str(thinking);
|
||||
text.push_str("\n</thinking>\n");
|
||||
}
|
||||
InputContentBlock::RedactedThinking { .. } => {
|
||||
text.push_str("<thinking>\n<redacted>\n</thinking>\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
if text.is_empty() && tool_calls.is_empty() {
|
||||
@@ -708,7 +715,9 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
|
||||
"content": flatten_tool_result_content(content),
|
||||
"is_error": is_error,
|
||||
})),
|
||||
InputContentBlock::ToolUse { .. } => None,
|
||||
InputContentBlock::ToolUse { .. }
|
||||
| InputContentBlock::Thinking { .. }
|
||||
| InputContentBlock::RedactedThinking { .. } => None,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
||||
+91
-3
@@ -1,5 +1,7 @@
|
||||
use crate::error::ApiError;
|
||||
use crate::types::StreamEvent;
|
||||
use serde_json::Value;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SseParser {
|
||||
@@ -95,9 +97,75 @@ pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
serde_json::from_str::<StreamEvent>(&payload)
|
||||
.map(Some)
|
||||
.map_err(ApiError::from)
|
||||
if matches!(event_name, Some("error")) {
|
||||
return Err(parse_error_event(&payload));
|
||||
}
|
||||
|
||||
// Some "Anthropic-compatible" gateways put the event type in the SSE `event:` field,
|
||||
// and omit the `{ "type": ... }` discriminator from the JSON `data:` payload.
|
||||
// Our Rust enums are tagged with `#[serde(tag = "type")]`, so we synthesize it here.
|
||||
match serde_json::from_str::<StreamEvent>(&payload) {
|
||||
Ok(event) => Ok(Some(event)),
|
||||
Err(error) => {
|
||||
// Best-effort: if we have an SSE event name and the payload is a JSON object
|
||||
// without a `type` field, inject it and retry.
|
||||
let Some(event_name) = event_name else {
|
||||
return Err(ApiError::from(error));
|
||||
};
|
||||
let Ok(Value::Object(mut object)) = serde_json::from_str::<Value>(&payload) else {
|
||||
return Err(ApiError::from(error));
|
||||
};
|
||||
if object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == "error")
|
||||
{
|
||||
return Err(parse_error_object(&object, payload));
|
||||
}
|
||||
if object.contains_key("type") {
|
||||
return Err(ApiError::from(error));
|
||||
}
|
||||
object.insert("type".to_string(), Value::String(event_name.to_string()));
|
||||
serde_json::from_value::<StreamEvent>(Value::Object(object))
|
||||
.map(Some)
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_error_event(payload: &str) -> ApiError {
|
||||
match serde_json::from_str::<Value>(payload) {
|
||||
Ok(Value::Object(object)) => parse_error_object(&object, payload.to_string()),
|
||||
_ => ApiError::Api {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
error_type: Some("stream_error".to_string()),
|
||||
message: Some(payload.to_string()),
|
||||
body: payload.to_string(),
|
||||
retryable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_error_object(object: &serde_json::Map<String, Value>, body: String) -> ApiError {
|
||||
let nested = object.get("error").and_then(Value::as_object);
|
||||
let error_type = nested
|
||||
.and_then(|error| error.get("type"))
|
||||
.or_else(|| object.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let message = nested
|
||||
.and_then(|error| error.get("message"))
|
||||
.or_else(|| object.get("message"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
ApiError::Api {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
error_type,
|
||||
message,
|
||||
body,
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -195,6 +263,26 @@ mod tests {
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_event_name_when_payload_omits_type() {
|
||||
let frame = concat!("event: message_stop\n", "data: {}\n\n");
|
||||
let event = parse_frame(frame).expect("frame should parse");
|
||||
assert_eq!(event, Some(StreamEvent::MessageStop(crate::types::MessageStopEvent {})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surfaces_stream_error_events() {
|
||||
let frame = concat!(
|
||||
"event: error\n",
|
||||
"data: {\"error\":{\"type\":\"invalid_request_error\",\"message\":\"bad input\"}}\n\n"
|
||||
);
|
||||
let error = parse_frame(frame).expect_err("error frame should surface");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"api returned 502 Bad Gateway (invalid_request_error): bad input"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_split_json_across_data_lines() {
|
||||
let frame = concat!(
|
||||
|
||||
@@ -75,6 +75,14 @@ pub enum InputContentBlock {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
is_error: bool,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
},
|
||||
RedactedThinking {
|
||||
data: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -22,6 +22,7 @@ serde_json.workspace = true
|
||||
syntect = "5"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
dotenvy = "0.15"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Claw CLI 模块 (claw-cli)
|
||||
|
||||
本模块实现了 Claw 应用程序的主要命令行界面 (CLI)。它提供了交互式的 REPL 环境和非交互式的命令执行功能。
|
||||
|
||||
## 概览
|
||||
|
||||
`claw-cli` 模块是整个项目的“胶水”,负责编排 `runtime`、`api`、`tools` 和 `plugins` 模块之间的交互。它捕获用户输入,管理应用程序状态,并以用户友好的格式渲染 AI 响应。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **交互式 REPL**:一个功能齐全的 Read-Eval-Print Loop,用于与 AI 对话,支持:
|
||||
- 通过 `rustyline` 实现多行输入和命令历史。
|
||||
- 实时的 Markdown 流式显示和代码语法高亮。
|
||||
- 为耗时的工具调用显示动态的加载动画 (Spinner) 和进度指示器。
|
||||
- **子命令**:
|
||||
- `prompt`:运行单次 Prompt 并退出(单次模式)。
|
||||
- `login`/`logout`:处理与 Claw 平台的 OAuth 身份验证。
|
||||
- `init`:初始化新项目/仓库以配合 Claw 使用。
|
||||
- `resume`:恢复并继续之前的对话会话。
|
||||
- `agents`/`skills`:管理并发现可用的智能体和技能。
|
||||
- **权限管理**:对工具执行权限的细粒度控制:
|
||||
- `read-only`:安全的分析模式。
|
||||
- `workspace-write`:允许在当前工作区内进行修改。
|
||||
- `danger-full-access`:高级任务的无限制访问。
|
||||
- **OAuth 流程**:集成了本地 HTTP 服务器,无缝处理用户机器上的 OAuth 回调重定向。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **`main.rs`**: 主要入口点。负责解析命令行参数、初始化环境,并调度到适当的操作(REPL 或子命令)。
|
||||
- **`render/`**: 包含 `TerminalRenderer` 和 Markdown 流渲染逻辑。使用 `syntect` 进行语法高亮,并使用 `crossterm` 进行终端操作。
|
||||
- **`input/`**: 处理用户输入捕获,包括对多行 Prompt 和斜杠命令 (Slash Command) 的特殊处理。
|
||||
- **`init.rs`**: 处理项目级初始化和仓库设置。
|
||||
|
||||
### 交互循环流程
|
||||
|
||||
1. CLI 初始化 `ConversationRuntime` 并加载项目上下文。
|
||||
2. 使用 `rustyline` 进入捕获用户输入的循环。
|
||||
3. 检查用户输入是否为“斜杠命令”(如 `/compact`、`/model`)。
|
||||
4. 普通 Prompt 通过 `runtime` 发送给 AI。
|
||||
5. AI 事件(文本增量、工具调用)逐步渲染到终端。
|
||||
6. 会话定期保存,以便将来可以恢复。
|
||||
|
||||
## 使用方法
|
||||
|
||||
主二进制程序名为 `claw`。
|
||||
|
||||
```bash
|
||||
# 启动交互式 REPL
|
||||
claw
|
||||
|
||||
# 运行单次 Prompt
|
||||
claw prompt "解释一下这个项目的架构"
|
||||
|
||||
# 登录服务
|
||||
claw login
|
||||
```
|
||||
@@ -59,6 +59,7 @@ const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3);
|
||||
type AllowedToolSet = BTreeSet<String>;
|
||||
|
||||
fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{}", render_cli_error(&error.to_string()));
|
||||
std::process::exit(1);
|
||||
@@ -171,7 +172,7 @@ impl CliOutputFormat {
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let mut model = DEFAULT_MODEL.to_string();
|
||||
let mut model = env::var("CLAW_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
|
||||
let mut output_format = CliOutputFormat::Text;
|
||||
let mut permission_mode = default_permission_mode();
|
||||
let mut wants_version = false;
|
||||
@@ -2504,6 +2505,12 @@ fn render_export_text(session: &Session) -> String {
|
||||
for block in &message.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => lines.push(text.clone()),
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
lines.push(format!("[thinking] {thinking}"));
|
||||
}
|
||||
ContentBlock::RedactedThinking { .. } => {
|
||||
lines.push("[thinking] <redacted>".to_string());
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
lines.push(format!("[tool_use id={id} name={name}] {input}"));
|
||||
}
|
||||
@@ -3158,8 +3165,18 @@ impl ApiClient for DefaultRuntimeClient {
|
||||
input.push_str(&partial_json);
|
||||
}
|
||||
}
|
||||
ContentBlockDelta::ThinkingDelta { .. }
|
||||
| ContentBlockDelta::SignatureDelta { .. } => {}
|
||||
ContentBlockDelta::ThinkingDelta { thinking } => {
|
||||
if !thinking.is_empty() {
|
||||
if self.emit_output {
|
||||
// Use a dimmed style for thinking blocks
|
||||
write!(out, "\x1b[2m{thinking}\x1b[0m")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
}
|
||||
events.push(AssistantEvent::ThinkingDelta(thinking));
|
||||
}
|
||||
}
|
||||
ContentBlockDelta::SignatureDelta { .. } => {}
|
||||
},
|
||||
ApiStreamEvent::ContentBlockStop(_) => {
|
||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
||||
@@ -3237,7 +3254,10 @@ fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
|
||||
ContentBlock::RedactedThinking { .. }
|
||||
| ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
@@ -3256,7 +3276,10 @@ fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> {
|
||||
"name": name,
|
||||
"input": input,
|
||||
})),
|
||||
_ => None,
|
||||
ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. }
|
||||
| ContentBlock::Text { .. }
|
||||
| ContentBlock::ToolResult { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -3278,7 +3301,10 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec<serde_json::Value
|
||||
"output": output,
|
||||
"is_error": is_error,
|
||||
})),
|
||||
_ => None,
|
||||
ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. }
|
||||
| ContentBlock::Text { .. }
|
||||
| ContentBlock::ToolUse { .. } => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -3819,7 +3845,16 @@ fn push_output_block(
|
||||
};
|
||||
*pending_tool = Some((id, name, initial_input));
|
||||
}
|
||||
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
|
||||
OutputContentBlock::Thinking { thinking, .. } => {
|
||||
if !thinking.is_empty() {
|
||||
// Dimmed style for thinking
|
||||
write!(out, "\x1b[2m{thinking}\x1b[0m")
|
||||
.and_then(|()| out.flush())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
events.push(AssistantEvent::ThinkingDelta(thinking));
|
||||
}
|
||||
}
|
||||
OutputContentBlock::RedactedThinking { .. } => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3928,6 +3963,16 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => InputContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
},
|
||||
ContentBlock::RedactedThinking { data } => InputContentBlock::RedactedThinking {
|
||||
data: serde_json::from_str(&data.render()).unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 命令模块 (commands)
|
||||
|
||||
本模块负责定义和管理 Claw 交互界面中使用的“斜杠命令”(Slash Commands),并提供相关的解析和执行逻辑。
|
||||
|
||||
## 概览
|
||||
|
||||
`commands` 模块的主要职责包括:
|
||||
- 定义所有可用的斜杠命令及其元数据(别名、说明、类别等)。
|
||||
- 提供命令注册表 (`CommandRegistry`),用于在 CLI 中发现和分发命令。
|
||||
- 实现复杂的管理命令,如插件管理 (`/plugins`)、智能体查看 (`/agents`) 和技能查看 (`/skills`)。
|
||||
- 提供命令建议功能,支持基于编辑距离 (Levenshtein distance) 的模糊匹配。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **斜杠命令规范 (SlashCommandSpec)**:每个命令都包含详尽的元数据,包括所属类别(核心、工作区、会话、Git、自动化)以及是否支持在恢复会话时执行。
|
||||
- **命令分类**:
|
||||
- **核心 (Core)**:`/help`, `/status`, `/model`, `/permissions`, `/cost` 等。
|
||||
- **工作区 (Workspace)**:`/config`, `/memory`, `/diff`, `/teleport` 等。
|
||||
- **会话 (Session)**:`/clear`, `/resume`, `/export`, `/session` 等。
|
||||
- **Git 交互**:`/branch`, `/commit`, `/pr`, `/issue` 等。
|
||||
- **自动化 (Automation)**:`/plugins`, `/agents`, `/skills`, `/ultraplan` 等。
|
||||
- **模糊匹配与建议**:当用户输入错误的命令时,系统会自动推荐最接近的合法命令。
|
||||
- **插件集成**:`/plugins` 命令允许用户动态安装、启用、禁用或卸载插件,并能通知运行时重新加载环境。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **`lib.rs`**: 包含了绝大部分逻辑。
|
||||
- **`SlashCommand` 枚举**: 定义了所有命令的强类型表示。
|
||||
- **`SlashCommandSpec` 结构体**: 存储命令的静态配置信息。
|
||||
- **`handle_plugins_slash_command`**: 处理复杂的插件管理工作流。
|
||||
- **`suggest_slash_commands`**: 实现基于 Levenshtein 距离的建议算法。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. 用户在 REPL 中输入以 `/` 开头的字符串。
|
||||
2. `claw-cli` 调用 `SlashCommand::parse` 进行解析。
|
||||
3. 解析后的命令被分发到相应的处理器。
|
||||
4. 处理结果(通常包含要显示给用户的消息,以及可选的会话更新或运行时重新加载请求)返回给 CLI。
|
||||
|
||||
## 使用示例 (内部)
|
||||
|
||||
```rust
|
||||
use commands::{SlashCommand, suggest_slash_commands};
|
||||
|
||||
// 解析命令
|
||||
if let Some(cmd) = SlashCommand::parse("/model sonnet") {
|
||||
// 处理模型切换逻辑
|
||||
}
|
||||
|
||||
// 获取建议
|
||||
let suggestions = suggest_slash_commands("hpel", 3);
|
||||
// 返回 ["/help"]
|
||||
```
|
||||
@@ -603,7 +603,7 @@ pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ranked.sort_by(|left, right| left.cmp(right));
|
||||
ranked.sort();
|
||||
ranked.dedup_by(|left, right| left.2 == right.2);
|
||||
ranked
|
||||
.into_iter()
|
||||
@@ -842,7 +842,7 @@ pub fn handle_branch_slash_command(
|
||||
Ok(if trimmed.is_empty() {
|
||||
"Branch\n Result no branches found".to_string()
|
||||
} else {
|
||||
format!("Branch\n Result listed\n\n{}", trimmed)
|
||||
format!("Branch\n Result listed\n\n{trimmed}")
|
||||
})
|
||||
}
|
||||
Some("create") => {
|
||||
@@ -882,7 +882,7 @@ pub fn handle_worktree_slash_command(
|
||||
Ok(if trimmed.is_empty() {
|
||||
"Worktree\n Result no worktrees found".to_string()
|
||||
} else {
|
||||
format!("Worktree\n Result listed\n\n{}", trimmed)
|
||||
format!("Worktree\n Result listed\n\n{trimmed}")
|
||||
})
|
||||
}
|
||||
Some("add") => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 兼容性测试套件模块 (compat-harness)
|
||||
|
||||
本模块提供了一套工具,专门用于分析和提取上游引用实现(如原始的 `claude-code` TypeScript 源码)中的元数据,以确保 Rust 版本的实现与其保持功能兼容。
|
||||
|
||||
## 概览
|
||||
|
||||
`compat-harness` 的主要职责是:
|
||||
- 定位上游仓库的源码路径。
|
||||
- 从 TypeScript 源码文件中提取命令 (`commands`)、工具 (`tools`) 和启动阶段 (`bootstrap phases`) 的定义。
|
||||
- 自动生成功能清单 (`ExtractedManifest`),供运行时或测试使用,以验证 Rust 版本的覆盖率。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **上游路径解析 (UpstreamPaths)**:能够自动识别多种常见的上游仓库目录结构,并支持通过环境变量 `CLAUDE_CODE_UPSTREAM` 进行覆盖。
|
||||
- **静态代码分析**:通过解析 TypeScript 源码,识别特定的代码模式(如 `export const INTERNAL_ONLY_COMMANDS` 或基于 `feature()` 的功能开关)。
|
||||
- **清单提取 (Manifest Extraction)**:
|
||||
- **命令提取**:识别内置命令、仅限内部使用的命令以及受功能开关控制的命令。
|
||||
- **工具提取**:识别基础工具和条件加载的工具。
|
||||
- **启动计划提取**:分析 CLI 入口文件,重建启动时的各个阶段(如 `FastPathVersion`, `MainRuntime` 等)。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **`lib.rs`**: 包含了核心的提取逻辑。
|
||||
- **`UpstreamPaths` 结构体**: 封装了寻找 `commands.ts`、`tools.ts` 和 `cli.tsx` 的逻辑。
|
||||
- **`extract_commands` & `extract_tools`**: 使用字符串解析技术,识别 TypeScript 的 `import` 和赋值操作,提取符号名称。
|
||||
- **`extract_bootstrap_plan`**: 搜索特定的标志性字符串(如 `--version` 或 `daemon-worker`),从而推断出上游程序的启动流程。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. 模块根据预设路径或环境变量寻找上游 `claude-code` 仓库。
|
||||
2. 读取关键的 `.ts` 或 `.tsx` 文件内容。
|
||||
3. 执行正则表达式风格的行解析,提取出所有定义的命令和工具名称。
|
||||
4. 将提取结果组织成 `ExtractedManifest` 对象。
|
||||
|
||||
## 使用示例 (内部测试)
|
||||
|
||||
```rust
|
||||
use compat_harness::{UpstreamPaths, extract_manifest};
|
||||
|
||||
// 指定工作区目录,自动寻找上游路径
|
||||
let paths = UpstreamPaths::from_workspace_dir("path/to/workspace");
|
||||
// 提取功能清单
|
||||
if let Ok(manifest) = extract_manifest(&paths) {
|
||||
println!("上游发现 {} 个工具", manifest.tools.entries().len());
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
# LSP 模块 (lsp)
|
||||
|
||||
本模块实现了语言服务协议 (Language Server Protocol, LSP) 的客户端功能,允许系统通过集成的编程语言服务器获取代码的语义信息、错误诊断和符号导航。
|
||||
|
||||
## 概览
|
||||
|
||||
`lsp` 模块的主要职责是:
|
||||
- 管理多个 LSP 服务器的生命周期(启动、初始化、关闭)。
|
||||
- 与服务器进行异步 JSON-RPC 通信。
|
||||
- 提供跨语言的代码智能功能,如:
|
||||
- **转到定义 (Go to Definition)**
|
||||
- **查找引用 (Find References)**
|
||||
- **工作区诊断 (Workspace Diagnostics)**
|
||||
- 为 AI 提示词 (Prompt) 提供上下文增强,将代码中的实时错误和符号关系反馈给 LLM。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **LspManager**: 核心管理类,负责协调不同语言的服务器配置和文档状态。
|
||||
- **上下文增强 (Context Enrichment)**:定义了 `LspContextEnrichment` 结构,能够将复杂的 LSP 响应(如诊断信息和定义)转换为易于 AI 理解的 Markdown 格式。
|
||||
- **多服务器支持**:支持根据文件扩展名将请求路由到不同的语言服务器(如 `rust-analyzer`, `pyright` 等)。
|
||||
- **同步机制**:处理文档的 `didOpen`、`didChange` 和 `didSave` 消息,确保服务器拥有最新的代码视图。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **`manager.rs`**: 实现了 `LspManager`。它维护一个服务器池,并提供高层 API 来执行跨服务器的请求。
|
||||
- **`client.rs`**: 实现底层的 LSP 客户端逻辑,处理基于 `tokio` 的异步 I/O 和 JSON-RPC 消息的分帧与解析。
|
||||
- **`types.rs`**: 定义了本模块使用的专用数据类型,并对 `lsp-types` 库中的类型进行了简化和包装,以便于内部使用。
|
||||
- **`error.rs`**: 定义了 LSP 相关的错误处理。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. 系统根据配置初始化 `LspManager`。
|
||||
2. 当打开一个文件时,`LspManager` 启动相应的服务器并发送 `initialize` 请求。
|
||||
3. `LspManager` 跟踪文档的打开状态,并在内容变化时同步到服务器。
|
||||
4. 当需要对某个符号进行分析时,调用 `go_to_definition` 等方法,模块负责发送请求并解析返回的 `Location`。
|
||||
5. 诊断信息异步通过 `textDocument/publishDiagnostics` 通知到达,模块会缓存这些信息供后续查询。
|
||||
|
||||
## 使用示例 (内部)
|
||||
|
||||
```rust
|
||||
use lsp::{LspManager, LspServerConfig};
|
||||
|
||||
// 配置并初始化管理器
|
||||
let configs = vec![LspServerConfig {
|
||||
name: "rust-analyzer".to_string(),
|
||||
command: "rust-analyzer".to_string(),
|
||||
..Default::default()
|
||||
}];
|
||||
let manager = LspManager::new(configs)?;
|
||||
|
||||
// 获取某个位置的上下文增强信息
|
||||
let enrichment = manager.context_enrichment(&file_path, position).await?;
|
||||
println!("{}", enrichment.render_prompt_section());
|
||||
```
|
||||
@@ -15,11 +15,13 @@ use tokio::sync::{oneshot, Mutex};
|
||||
use crate::error::LspError;
|
||||
use crate::types::{LspServerConfig, SymbolLocation};
|
||||
|
||||
type PendingRequestMap = BTreeMap<i64, oneshot::Sender<Result<Value, LspError>>>;
|
||||
|
||||
pub(crate) struct LspClient {
|
||||
config: LspServerConfig,
|
||||
writer: Mutex<BufWriter<ChildStdin>>,
|
||||
child: Mutex<Child>,
|
||||
pending_requests: Arc<Mutex<BTreeMap<i64, oneshot::Sender<Result<Value, LspError>>>>>,
|
||||
pending_requests: Arc<Mutex<PendingRequestMap>>,
|
||||
diagnostics: Arc<Mutex<BTreeMap<String, Vec<Diagnostic>>>>,
|
||||
open_documents: Mutex<BTreeMap<PathBuf, i32>>,
|
||||
next_request_id: AtomicI64,
|
||||
@@ -59,7 +61,7 @@ impl LspClient {
|
||||
|
||||
client.spawn_reader(stdout);
|
||||
if let Some(stderr) = stderr {
|
||||
client.spawn_stderr_drain(stderr);
|
||||
Self::spawn_stderr_drain(stderr);
|
||||
}
|
||||
client.initialize().await?;
|
||||
Ok(client)
|
||||
@@ -282,8 +284,8 @@ impl LspClient {
|
||||
if let Err(error) = result {
|
||||
let mut pending = pending_requests.lock().await;
|
||||
let drained = pending
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.keys()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
for id in drained {
|
||||
if let Some(sender) = pending.remove(&id) {
|
||||
@@ -294,7 +296,7 @@ impl LspClient {
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_stderr_drain<R>(&self, stderr: R)
|
||||
fn spawn_stderr_drain<R>(stderr: R)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 插件模块 (plugins)
|
||||
|
||||
本模块实现了 Claw 的插件系统,允许通过外部扩展来增强 AI 的功能,包括自定义工具、命令以及在工具执行前后运行的钩子 (Hooks)。
|
||||
|
||||
## 概览
|
||||
|
||||
`plugins` 模块的主要职责是:
|
||||
- 定义插件的清单格式 (`plugin.json`) 和元数据结构。
|
||||
- 管理插件的完整生命周期:安装、加载、初始化、启用/禁用、更新和卸载。
|
||||
- 提供插件类型的抽象:
|
||||
- **Builtin (内置)**:编译在程序内部的插件。
|
||||
- **Bundled (绑定)**:随应用程序分发但作为独立文件存在的插件。
|
||||
- **External (外部)**:用户自行安装或从远程仓库下载的插件。
|
||||
- 实现插件隔离与执行机制,支持插件定义的自定义工具。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **插件清单 (PluginManifest)**:每个插件必须包含一个 `plugin.json`,详细说明其名称、版本、所需权限、钩子、生命周期脚本以及它所暴露的工具。
|
||||
- **自定义工具 (PluginTool)**:插件可以定义全新的工具供 AI 调用。这些工具在执行时被作为独立的外部进程启动。
|
||||
- **钩子系统 (Hooks)**:支持 `PreToolUse` 和 `PostToolUse` 钩子,允许插件在 AI 调用任何工具之前或之后执行特定的逻辑。
|
||||
- **生命周期管理**:提供 `Init` 和 `Shutdown` 阶段,允许插件在加载时进行环境准备,在卸载或关闭时进行清理。
|
||||
- **权限模型**:强制要求插件声明权限(`read`, `write`, `execute`),并为定义的工具指定安全级别(`read-only`, `workspace-write`, `danger-full-access`)。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **`lib.rs`**: 包含了插件定义的各种结构体(Manifest, Metadata, Tool, Permission 等)以及插件特性的定义。
|
||||
- **`manager.rs`**: 实现了 `PluginManager`,负责插件在磁盘上的组织、注册表的维护以及安装/更新逻辑。
|
||||
- **`hooks.rs`**: 实现了钩子执行器 (`HookRunner`),负责在正确的时机触发插件定义的脚本。
|
||||
|
||||
### 插件加载与执行流程
|
||||
|
||||
1. `PluginManager` 扫描指定的目录(内置、绑定及外部安装目录)。
|
||||
2. 读取并验证每个插件的 `plugin.json`。
|
||||
3. 如果插件被启用,则初始化该插件并将其定义的工具注册到系统的全局工具注册表中。
|
||||
4. 当 AI 调用插件工具时,系统根据清单中定义的命令行信息启动一个子进程,并通过标准输入/环境变量传递参数。
|
||||
5. 钩子逻辑会在工具执行的生命周期内被自动触发。
|
||||
|
||||
## 使用示例 (插件定义样例)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-custom-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "一个演示插件",
|
||||
"permissions": ["read", "execute"],
|
||||
"tools": [
|
||||
{
|
||||
"name": "custom_search",
|
||||
"description": "执行自定义搜索",
|
||||
"inputSchema": { "type": "object", "properties": { "query": { "type": "string" } } },
|
||||
"command": "python3",
|
||||
"args": ["search_script.py"],
|
||||
"requiredPermission": "read-only"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(not(windows))]
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 运行时模块 (runtime)
|
||||
|
||||
本模块是 Claw 的核心引擎,负责协调 AI 模型、工具执行、会话管理和权限控制之间的所有交互。
|
||||
|
||||
## 概览
|
||||
|
||||
`runtime` 模块是整个系统的“中枢神经”,其主要职责包括:
|
||||
- **对话驱动**:管理“用户-助手-工具”的循环迭代。
|
||||
- **会话持久化**:负责会话的加载、保存及历史记录的压缩 (Compaction)。
|
||||
- **MCP 客户端**:实现模型上下文协议 (Model Context Protocol),支持与外部 MCP 服务器通信。
|
||||
- **安全沙箱与权限**:实施基于策略的工具执行权限检查。
|
||||
- **上下文构建**:动态生成系统提示词 (System Prompt),集成工作区上下文。
|
||||
- **消耗统计**:精确跟踪 Token 使用情况和 Token 缓存状态。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **ConversationRuntime**:核心驱动类,支持流式响应处理和多轮工具调用迭代。
|
||||
- **权限引擎 (Permissions)**:提供多种模式(`ReadOnly`, `WorkspaceWrite`, `DangerFullAccess`),并支持交互式权限确认。
|
||||
- **会话压缩 (Compaction)**:当对话历史过长影响性能或成本时,自动将旧消息总结为摘要,保持上下文精简。
|
||||
- **钩子集成 (Hooks)**:在工具执行的前后触发插件定义的钩子,支持干预工具输入或处理执行结果。
|
||||
- **沙箱执行 (Sandbox)**:为 Bash 等敏感工具提供受限的执行环境。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心子模块
|
||||
|
||||
- **`conversation.rs`**: 定义了核心的 `ConversationRuntime` 和 `ApiClient`/`ToolExecutor` 特性。
|
||||
- **`mcp_stdio.rs` / `mcp_client.rs`**: 实现了完整的 MCP 规范,支持通过标准输入/输出与外部工具服务器交互。
|
||||
- **`session.rs`**: 定义了消息模型 (`ConversationMessage`)、内容块 (`ContentBlock`) 和会话序列化逻辑。
|
||||
- **`permissions.rs`**: 实现了权限审核逻辑和提示器接口。
|
||||
- **`compact.rs`**: 包含了基于 LLM 的会话摘要生成和历史裁剪算法。
|
||||
- **`config.rs`**: 负责加载和合并多层级的配置文件。
|
||||
|
||||
### 对话循环流程 (run_turn)
|
||||
|
||||
1. 将用户输入推入 `Session`。
|
||||
2. 调用 `ApiClient` 发起流式请求。
|
||||
3. 监听 `AssistantEvent`,解析文本内容和工具调用请求。
|
||||
4. **工具权限审核**:针对每个 `ToolUse`,根据 `PermissionPolicy` 决定是允许、拒绝还是询问用户。
|
||||
5. **执行工具**:若允许,则通过 `ToolExecutor`(或 MCP 客户端)执行工具,并运行相关的 `Pre/Post Hooks`。
|
||||
6. 将工具结果反馈给 AI,进入下一轮迭代,直到 AI 给出最终回复。
|
||||
|
||||
## 使用示例 (内部)
|
||||
|
||||
```rust
|
||||
use runtime::{ConversationRuntime, Session, PermissionPolicy, PermissionMode};
|
||||
|
||||
// 初始化运行时
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
api_client,
|
||||
tool_executor,
|
||||
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
||||
system_prompt,
|
||||
);
|
||||
|
||||
// 运行一轮对话
|
||||
let summary = runtime.run_turn("帮我重构 src/lib.rs", Some(&mut cli_prompter))?;
|
||||
println!("共迭代 {} 次,消耗 {} tokens", summary.iterations, summary.usage.total_tokens());
|
||||
```
|
||||
@@ -160,7 +160,9 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||
ContentBlock::Text { .. } => None,
|
||||
ContentBlock::Text { .. }
|
||||
| ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tool_names.sort_unstable();
|
||||
@@ -275,6 +277,8 @@ fn summarize_block(block: &ContentBlock) -> String {
|
||||
"tool_result {tool_name}: {}{output}",
|
||||
if *is_error { "error " } else { "" }
|
||||
),
|
||||
ContentBlock::Thinking { thinking, .. } => format!("thinking: {thinking}"),
|
||||
ContentBlock::RedactedThinking { .. } => "thinking: <redacted>".to_string(),
|
||||
};
|
||||
truncate_summary(&raw, 160)
|
||||
}
|
||||
@@ -324,6 +328,8 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => text.as_str(),
|
||||
ContentBlock::Thinking { thinking, .. } => thinking.as_str(),
|
||||
ContentBlock::RedactedThinking { .. } => "",
|
||||
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
||||
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||
})
|
||||
@@ -348,7 +354,9 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
||||
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
||||
ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::Text { .. } => None,
|
||||
| ContentBlock::Text { .. }
|
||||
| ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,6 +402,8 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => text.len() / 4 + 1,
|
||||
ContentBlock::Thinking { thinking, .. } => thinking.len() / 4 + 1,
|
||||
ContentBlock::RedactedThinking { .. } => 1,
|
||||
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct ApiRequest {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AssistantEvent {
|
||||
TextDelta(String),
|
||||
ThinkingDelta(String),
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -122,6 +123,7 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
#[must_use]
|
||||
pub fn new_with_features(
|
||||
session: Session,
|
||||
@@ -299,6 +301,16 @@ fn build_assistant_message(
|
||||
for event in events {
|
||||
match event {
|
||||
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
||||
AssistantEvent::ThinkingDelta(delta) => {
|
||||
if let Some(ContentBlock::Thinking { thinking, .. }) = blocks.last_mut() {
|
||||
thinking.push_str(&delta);
|
||||
} else {
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
thinking: delta,
|
||||
signature: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
AssistantEvent::ToolUse { id, name, input } => {
|
||||
flush_text_block(&mut text, &mut blocks);
|
||||
blocks.push(ContentBlock::ToolUse { id, name, input });
|
||||
|
||||
@@ -74,7 +74,7 @@ impl HookRunner {
|
||||
|
||||
#[must_use]
|
||||
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
|
||||
self.run_commands(
|
||||
Self::run_commands(
|
||||
HookEvent::PreToolUse,
|
||||
self.config.pre_tool_use(),
|
||||
tool_name,
|
||||
@@ -92,7 +92,7 @@ impl HookRunner {
|
||||
tool_output: &str,
|
||||
is_error: bool,
|
||||
) -> HookRunResult {
|
||||
self.run_commands(
|
||||
Self::run_commands(
|
||||
HookEvent::PostToolUse,
|
||||
self.config.post_tool_use(),
|
||||
tool_name,
|
||||
@@ -103,7 +103,6 @@ impl HookRunner {
|
||||
}
|
||||
|
||||
fn run_commands(
|
||||
&self,
|
||||
event: HookEvent,
|
||||
commands: &[String],
|
||||
tool_name: &str,
|
||||
@@ -238,7 +237,7 @@ fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &
|
||||
|
||||
fn shell_command(command: &str) -> CommandWithStdin {
|
||||
#[cfg(windows)]
|
||||
let mut command_builder = {
|
||||
let command_builder = {
|
||||
let mut command_builder = Command::new("cmd");
|
||||
command_builder.arg("/C").arg(command);
|
||||
CommandWithStdin::new(command_builder)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum JsonValue {
|
||||
Null,
|
||||
Bool(bool),
|
||||
|
||||
@@ -892,9 +892,12 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
}
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1018,9 +1021,12 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
}
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1122,9 +1128,12 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
}
|
||||
script_path
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ pub enum MessageRole {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Thinking {
|
||||
thinking: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
},
|
||||
RedactedThinking {
|
||||
data: JsonValue,
|
||||
},
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
@@ -261,6 +269,26 @@ impl ContentBlock {
|
||||
object.insert("type".to_string(), JsonValue::String("text".to_string()));
|
||||
object.insert("text".to_string(), JsonValue::String(text.clone()));
|
||||
}
|
||||
Self::Thinking { thinking, signature } => {
|
||||
object.insert("type".to_string(), JsonValue::String("thinking".to_string()));
|
||||
object.insert(
|
||||
"thinking".to_string(),
|
||||
JsonValue::String(thinking.clone()),
|
||||
);
|
||||
if let Some(signature) = signature {
|
||||
object.insert(
|
||||
"signature".to_string(),
|
||||
JsonValue::String(signature.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::RedactedThinking { data } => {
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
JsonValue::String("redacted_thinking".to_string()),
|
||||
);
|
||||
object.insert("data".to_string(), data.clone());
|
||||
}
|
||||
Self::ToolUse { id, name, input } => {
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
@@ -312,6 +340,13 @@ impl ContentBlock {
|
||||
name: required_string(object, "name")?,
|
||||
input: required_string(object, "input")?,
|
||||
}),
|
||||
"thinking" => Ok(Self::Thinking {
|
||||
thinking: required_string(object, "thinking")?,
|
||||
signature: object.get("signature").and_then(JsonValue::as_str).map(ToOwned::to_owned),
|
||||
}),
|
||||
"redacted_thinking" => Ok(Self::RedactedThinking {
|
||||
data: object.get("data").cloned().ok_or_else(|| SessionError::Format("missing data".to_string()))?,
|
||||
}),
|
||||
"tool_result" => Ok(Self::ToolResult {
|
||||
tool_use_id: required_string(object, "tool_use_id")?,
|
||||
tool_name: required_string(object, "tool_name")?,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Rusty Claude CLI 模块 (rusty-claude-cli)
|
||||
|
||||
本模块提供了 Claw 命令行界面的另一个功能完整的实现。它集成了对话、工具执行、插件扩展以及身份验证等核心功能。
|
||||
|
||||
## 概览
|
||||
|
||||
`rusty-claude-cli` 是一个全功能的 CLI 应用程序,其主要职责包括:
|
||||
- **用户交互**:提供交互式 REPL 和非交互式命令执行(`prompt` 子命令)。
|
||||
- **环境初始化**:处理项目初始化 (`init`) 和配置加载。
|
||||
- **身份验证**:通过本地回环服务器处理 OAuth 登录流程。
|
||||
- **状态渲染**:实现丰富的终端 UI 效果,如 Markdown 渲染、语法高亮和动态加载动画 (Spinner)。
|
||||
- **会话管理**:支持从保存的文件中恢复会话并执行追加的斜杠命令。
|
||||
|
||||
## 与 `claw-cli` 的关系
|
||||
|
||||
虽然 `rusty-claude-cli` 和 `claw-cli` 都生成名为 `claw` 的二进制文件,但 `rusty-claude-cli` 包含更复杂的集成逻辑:
|
||||
- 它直接引用了几乎所有的核心 crate(`runtime`, `api`, `tools`, `plugins`, `commands`)。
|
||||
- 它的 `main.rs` 实现非常庞大,包含了大量的业务编排逻辑。
|
||||
- 它可以作为一个独立的、集成度极高的 CLI 参考实现。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **多功能子命令**:
|
||||
- `prompt`:快速运行单次推理。
|
||||
- `login`/`logout`:OAuth 身份验证管理。
|
||||
- `init`:项目环境自举。
|
||||
- `bootstrap-plan`:查看系统的启动阶段。
|
||||
- `dump-manifests`:从上游源码中提取并显示功能清单。
|
||||
- **增强的 REPL**:
|
||||
- 支持多行输入和历史记录。
|
||||
- 集成了斜杠命令处理引擎。
|
||||
- 提供详细的消耗统计和权限模式切换报告。
|
||||
- **灵活的权限控制**:支持通过命令行参数 `--permission-mode` 或环境变量动态调整权限级别。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心子模块
|
||||
|
||||
- **`main.rs`**: 程序的入口,包含了复杂的参数解析逻辑和 REPL 循环。
|
||||
- **`render.rs`**: 封装了 `TerminalRenderer` 和 `Spinner`,负责所有的终端输出美化。
|
||||
- **`input.rs`**: 处理从标准输入读取数据及命令解析。
|
||||
- **`init.rs`**: 专注于仓库的初始化和 `.claw.md` 文件的生成。
|
||||
- **`app.rs`**: 可能包含应用程序级别的高层状态管理(取决于具体实现)。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. 程序启动,解析命令行参数。
|
||||
2. 根据参数决定是执行单次任务还是进入 REPL 模式。
|
||||
3. 在 REPL 模式下,初始化 `ConversationRuntime`。
|
||||
4. 进入循环:读取用户输入 -> 处理斜杠命令或发送给 AI -> 渲染响应 -> 执行工具 -> 循环。
|
||||
5. 会话数据根据需要保存或恢复。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 启动交互模式
|
||||
cargo run -p rusty-claude-cli --bin claw
|
||||
|
||||
# 直接运行 Prompt
|
||||
cargo run -p rusty-claude-cli --bin claw prompt "检查代码中的内存泄漏"
|
||||
|
||||
# 恢复之前的会话并执行压缩
|
||||
cargo run -p rusty-claude-cli --bin claw --resume session.json /compact
|
||||
```
|
||||
@@ -35,7 +35,7 @@ use runtime::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
|
||||
use plugins::{self, PluginManager, PluginManagerConfig};
|
||||
use plugins::{self};
|
||||
|
||||
const DEFAULT_MODEL: &str = "claude-opus-4-6";
|
||||
const DEFAULT_MAX_TOKENS: u32 = 32;
|
||||
@@ -1870,6 +1870,12 @@ fn render_export_text(session: &Session) -> String {
|
||||
for block in &message.blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text } => lines.push(text.clone()),
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
lines.push(format!("[thinking] {thinking}"));
|
||||
}
|
||||
ContentBlock::RedactedThinking { .. } => {
|
||||
lines.push("[thinking] <redacted>".to_string());
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
lines.push(format!("[tool_use id={id} name={name}] {input}"));
|
||||
}
|
||||
@@ -2352,6 +2358,16 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => InputContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
},
|
||||
ContentBlock::RedactedThinking { data } => InputContentBlock::RedactedThinking {
|
||||
data: serde_json::from_str(&data.render()).unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
|
||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "claw-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-stream = "0.3"
|
||||
axum = "0.8"
|
||||
@@ -12,6 +16,14 @@ runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "net", "time"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
api = { path = "../api" }
|
||||
tools = { path = "../tools" }
|
||||
plugins = { path = "../plugins" }
|
||||
commands = { path = "../commands" }
|
||||
dotenvy = "0.15"
|
||||
chrono = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 服务模块 (server)
|
||||
|
||||
本模块提供了一个基于 HTTP 的 RESTful API 和 Server-Sent Events (SSE) 流接口,允许通过网络远程管理和与 Claw 会话进行交互。
|
||||
|
||||
## 概览
|
||||
|
||||
`server` 模块将 `runtime` 的核心功能封装为 Web 服务,其主要职责包括:
|
||||
- **会话管理**:提供创建、列出和获取会话详情的端点。
|
||||
- **消息分发**:接收用户消息并将其路由到相应的会话实例。
|
||||
- **实时流推送**:通过 SSE 接口实时推送会话事件(如 AI 响应消息、状态快照)。
|
||||
- **状态维护**:在内存中管理多个活跃会话的生命周期。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **RESTful API**:使用 `axum` 框架实现,遵循现代 Web 服务标准。
|
||||
- **事件流 (SSE)**:支持 `text/event-stream`,允许客户端实时订阅会话更新。
|
||||
- **并发处理**:利用 `tokio` 和 `broadcast` 频道,支持多个客户端同时监听同一会话的事件。
|
||||
- **快照机制**:在建立连接时发送当前会话的完整快照,确保客户端能够同步历史状态。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心接口 (API Routes)
|
||||
|
||||
- `POST /sessions`: 创建一个新的对话会话。
|
||||
- `GET /sessions`: 列出所有活跃会话的简要信息。
|
||||
- `GET /sessions/{id}`: 获取指定会话的完整详细信息。
|
||||
- `POST /sessions/{id}/message`: 向指定会话发送一条新消息。
|
||||
- `GET /sessions/{id}/events`: 建立 SSE 连接,订阅该会话的实时事件流。
|
||||
|
||||
### 核心结构
|
||||
|
||||
- **`AppState`**: 存储全局状态,包括 `SessionStore` (由 `RwLock` 保护的哈希表) 和会话 ID 分配器。
|
||||
- **`Session`**: 封装了 `runtime::Session` 实例,并包含一个用于广播事件的 `broadcast::Sender`。
|
||||
- **`SessionEvent`**: 定义了流中传输的事件类型,包括 `Snapshot` (快照) 和 `Message` (新消息)。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. 启动服务并初始化 `AppState`。
|
||||
2. 客户端通过 `POST /sessions` 开启一个新会话。
|
||||
3. 客户端连接 `GET /sessions/{id}/events` 以监听响应。
|
||||
4. 客户端通过 `POST /sessions/{id}/message` 发送 Prompt。
|
||||
5. 服务端将消息存入 `runtime::Session`,并触发广播。SSE 流将该消息及后续的 AI 响应实时推送回客户端。
|
||||
|
||||
## 使用示例 (内部)
|
||||
|
||||
```rust
|
||||
use server::{app, AppState};
|
||||
use axum::Router;
|
||||
|
||||
// 创建应用路由
|
||||
let state = AppState::new();
|
||||
let router = app(state);
|
||||
|
||||
// 启动服务(示例)
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
```
|
||||
+941
-112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
use server::{app, AppState};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 尝试加载 .env
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse::<u16>()?;
|
||||
let addr = format!("{host}:{port}").parse::<SocketAddr>()?;
|
||||
|
||||
// 解析模型(支持别名,如 "opus" -> "claude-opus-4-6")
|
||||
let raw_model = env::var("CLAW_MODEL").unwrap_or_else(|_| "claude-opus-4-6".to_string());
|
||||
let model = api::resolve_model_alias(&raw_model).to_string();
|
||||
|
||||
// 动态日期
|
||||
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
// 构建系统提示词(从项目目录加载或使用默认值)
|
||||
let cwd = env::current_dir()?;
|
||||
let system_prompt =
|
||||
runtime::load_system_prompt(&cwd, &today, env::consts::OS, "unknown")
|
||||
.unwrap_or_else(|_| {
|
||||
vec![
|
||||
"You are a helpful AI assistant running inside the Claw web interface."
|
||||
.to_string(),
|
||||
]
|
||||
});
|
||||
|
||||
// 解析权限模式(从环境变量或配置,默认 WorkspaceWrite)
|
||||
let permission_mode = match env::var("CLAW_PERMISSION_MODE")
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
{
|
||||
"danger" | "DangerFullAccess" => runtime::PermissionMode::DangerFullAccess,
|
||||
"readonly" | "ReadOnly" => runtime::PermissionMode::ReadOnly,
|
||||
_ => {
|
||||
// 尝试从 .claw.json 配置读取
|
||||
let loader = runtime::ConfigLoader::default_for(&cwd);
|
||||
loader
|
||||
.load()
|
||||
.ok()
|
||||
.and_then(|config| config.permission_mode())
|
||||
.map(|resolved| match resolved {
|
||||
runtime::ResolvedPermissionMode::ReadOnly => runtime::PermissionMode::ReadOnly,
|
||||
runtime::ResolvedPermissionMode::WorkspaceWrite => runtime::PermissionMode::WorkspaceWrite,
|
||||
runtime::ResolvedPermissionMode::DangerFullAccess => runtime::PermissionMode::DangerFullAccess,
|
||||
})
|
||||
.unwrap_or(runtime::PermissionMode::WorkspaceWrite)
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化应用状态
|
||||
let state = AppState::new(model.clone(), system_prompt, permission_mode, cwd)?;
|
||||
|
||||
// 构建路由
|
||||
let router = app(state);
|
||||
|
||||
println!("Claw Server started");
|
||||
println!(" address: http://{addr}");
|
||||
println!(" pid: {}", std::process::id());
|
||||
println!(" model: {model}");
|
||||
println!(" tip: curl -X POST http://{addr}/sessions");
|
||||
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# 工具规范模块 (tools)
|
||||
|
||||
本模块定义了 AI 代理可以使用的所有内置工具的规范 (Schema)、权限要求以及分发逻辑。
|
||||
|
||||
## 概览
|
||||
|
||||
`tools` 模块充当了 AI 认知能力与物理操作之间的桥梁,其主要职责包括:
|
||||
- **工具定义**:使用 JSON Schema 定义每个工具的输入参数结构,以便 AI 正确调用。
|
||||
- **权限映射**:为每个工具分配安全等级(如只读、工作区写入、完全访问)。
|
||||
- **工具注册表 (GlobalToolRegistry)**:统一管理内置工具和由插件提供的动态工具。
|
||||
- **分发执行**:将 AI 生成的 JSON 调用分发到 `runtime` 模块中的具体实现。
|
||||
|
||||
## 关键特性
|
||||
|
||||
- **内置工具集 (MVP Tools)**:
|
||||
- **系统交互**:`bash`, `PowerShell`, `REPL`。
|
||||
- **文件操作**:`read_file`, `write_file`, `edit_file`。
|
||||
- **搜索与发现**:`glob_search`, `grep_search`, `ToolSearch`。
|
||||
- **网络与辅助**:`WebSearch`, `WebFetch`, `Sleep`。
|
||||
- **高级调度**:`Agent`(启动子代理), `Skill`(加载专用技能), `TodoWrite`(任务管理)。
|
||||
- **名称归一化**:支持工具别名(例如将 `grep` 映射为 `grep_search`),提高 AI 调用的稳健性。
|
||||
- **插件集成**:允许 `plugins` 模块注册自定义工具,并确保它们与内置工具不发生命名冲突。
|
||||
|
||||
## 实现逻辑
|
||||
|
||||
### 核心结构
|
||||
|
||||
- **`ToolSpec`**: 核心配置结构,存储工具的元数据(名称、描述、Schema、权限)。
|
||||
- **`GlobalToolRegistry`**: 负责维护工具列表,并提供 `definitions` 方法生成供 LLM 使用的工具 API 声明。
|
||||
- **`execute_tool`**: 顶级分发函数,负责将反序列化后的输入传递给底层的执行函数。
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. 系统初始化时,根据用户配置和加载的插件,构建 `GlobalToolRegistry`。
|
||||
2. 将工具定义转换为 AI 模型可理解的格式(由 `api` 模块处理)。
|
||||
3. 当接收到 AI 的工具调用请求时,`runtime::ConversationRuntime` 调用 `ToolExecutor`。
|
||||
4. `ToolExecutor` 委托给本模块的 `execute_tool` 函数。
|
||||
5. 本模块验证输入格式,并调用 `runtime` 提供的底层文件或进程操作 API。
|
||||
|
||||
## 使用示例 (工具定义)
|
||||
|
||||
```rust
|
||||
use tools::{ToolSpec, mvp_tool_specs};
|
||||
use serde_json::json;
|
||||
|
||||
// 获取所有 MVP 工具的规范
|
||||
let specs = mvp_tool_specs();
|
||||
for spec in specs {
|
||||
println!("工具: {}, 权限级别: {:?}", spec.name, spec.required_permission);
|
||||
}
|
||||
```
|
||||
+15
-4
@@ -1628,7 +1628,7 @@ fn build_agent_runtime(
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
|
||||
let allowed_tools = job.allowed_tools.clone();
|
||||
let api_client = ProviderRuntimeClient::new(model, allowed_tools.clone())?;
|
||||
let api_client = ProviderRuntimeClient::new(&model, allowed_tools.clone())?;
|
||||
let tool_executor = SubagentToolExecutor::new(allowed_tools);
|
||||
Ok(ConversationRuntime::new(
|
||||
Session::new(),
|
||||
@@ -1805,8 +1805,8 @@ struct ProviderRuntimeClient {
|
||||
}
|
||||
|
||||
impl ProviderRuntimeClient {
|
||||
fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
|
||||
let model = resolve_model_alias(&model).to_string();
|
||||
fn new(model: &str, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
|
||||
let model = resolve_model_alias(model).clone();
|
||||
let client = ProviderClient::from_model(&model).map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
|
||||
@@ -1818,6 +1818,7 @@ impl ProviderRuntimeClient {
|
||||
}
|
||||
|
||||
impl ApiClient for ProviderRuntimeClient {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
|
||||
.into_iter()
|
||||
@@ -1991,6 +1992,13 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
}],
|
||||
is_error: *is_error,
|
||||
},
|
||||
ContentBlock::Thinking { thinking, signature } => InputContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
},
|
||||
ContentBlock::RedactedThinking { data } => InputContentBlock::RedactedThinking {
|
||||
data: serde_json::from_str(&data.render()).unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(!content.is_empty()).then(|| InputMessage {
|
||||
@@ -2061,7 +2069,10 @@ fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
ContentBlock::Thinking { .. }
|
||||
| ContentBlock::RedactedThinking { .. }
|
||||
| ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
|
||||
Reference in New Issue
Block a user