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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user