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:
fengmengqi
2026-04-10 16:29:27 +08:00
parent 0782159ecd
commit 4a04faf926
56 changed files with 8611 additions and 172 deletions
+1
View File
@@ -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
+58
View File
@@ -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
```
+52 -7
View File
@@ -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(),