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)]
|
||||
|
||||
Reference in New Issue
Block a user