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
+53
View File
@@ -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?;
```
+1 -1
View File
@@ -634,7 +634,7 @@ struct ApiErrorEnvelope {
#[derive(Debug, Deserialize)]
struct ApiErrorBody {
#[serde(rename = "type")]
#[serde(alias = "code", rename = "type")]
error_type: String,
message: String,
}
+63
View File
@@ -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]
+25 -16
View File
@@ -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
View File
@@ -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!(
+8
View File
@@ -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)]