feat: chat stats, regenerate with version history, edit & resubmit, image/file attachments
This commit is contained in:
@@ -6,4 +6,4 @@ pub mod types;
|
||||
pub use error::{EngineError, Result};
|
||||
pub use manager::EngineManager;
|
||||
pub use remote::{stream_chat_remote, RemoteConfig};
|
||||
pub use types::{ChatMessage, ChatRequest, EngineConfig, EngineStatus};
|
||||
pub use types::{ChatMessage, ChatRequest, ChatStreamEvent, EngineConfig, EngineStatus};
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::{EngineError, Result};
|
||||
use crate::types::{ChatRequest, EngineConfig, EngineStatus};
|
||||
use crate::types::{ChatRequest, ChatStreamEvent, EngineConfig, EngineStatus};
|
||||
use futures::Stream;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
@@ -145,7 +145,7 @@ impl EngineManager {
|
||||
pub async fn stream_chat(
|
||||
&self,
|
||||
req: ChatRequest,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<String>>> {
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<ChatStreamEvent>>> {
|
||||
let guard = self.inner.lock().await;
|
||||
let handle = guard.as_ref().ok_or(EngineError::NotRunning)?;
|
||||
let url = format!("{}/v1/chat/completions", handle.base_url);
|
||||
@@ -211,7 +211,7 @@ pub(crate) fn sse_text_stream(
|
||||
+ Unpin
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> futures::stream::BoxStream<'static, Result<String>> {
|
||||
) -> futures::stream::BoxStream<'static, Result<ChatStreamEvent>> {
|
||||
use futures::StreamExt;
|
||||
|
||||
Box::pin(async_stream::stream! {
|
||||
@@ -251,12 +251,26 @@ pub(crate) fn sse_text_stream(
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if let Some(usage) = value.get("usage") {
|
||||
let prompt_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let completion_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
yield Ok(ChatStreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
});
|
||||
}
|
||||
let content = value
|
||||
.pointer("/choices/0/delta/content")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| value.get("content").and_then(|v| v.as_str()));
|
||||
if let Some(text) = content {
|
||||
yield Ok(text.to_string());
|
||||
yield Ok(ChatStreamEvent::Text(text.to_string()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::error::{EngineError, Result};
|
||||
use crate::manager::sse_text_stream;
|
||||
use crate::types::ChatRequest;
|
||||
use crate::types::{ChatRequest, ChatStreamEvent};
|
||||
|
||||
/// OpenAI 兼容的远程模型端点配置。
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -14,7 +14,7 @@ pub struct RemoteConfig {
|
||||
pub async fn stream_chat_remote(
|
||||
cfg: &RemoteConfig,
|
||||
req: ChatRequest,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<String>>> {
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<ChatStreamEvent>>> {
|
||||
let base = normalize_base(&cfg.base_url);
|
||||
let url = format!("{base}/chat/completions");
|
||||
let client = reqwest::Client::new();
|
||||
@@ -41,4 +41,3 @@ fn normalize_base(base: &str) -> String {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1,10 +1,64 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
/// 图片(data URL),非空时序列化为多模态 content 数组
|
||||
#[serde(default)]
|
||||
pub images: Vec<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: role.into(),
|
||||
content: content.into(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ChatMessage {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
map.serialize_entry("role", &self.role)?;
|
||||
if self.images.is_empty() {
|
||||
map.serialize_entry("content", &self.content)?;
|
||||
} else {
|
||||
let mut parts = Vec::new();
|
||||
if !self.content.trim().is_empty() {
|
||||
parts.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": self.content,
|
||||
}));
|
||||
}
|
||||
for image in &self.images {
|
||||
parts.push(serde_json::json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": image },
|
||||
}));
|
||||
}
|
||||
map.serialize_entry("content", &parts)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式聊天事件:增量文本 或 用量统计(部分引擎在最后一条 SSE 里给出)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatStreamEvent {
|
||||
Text(String),
|
||||
Usage {
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -57,4 +111,3 @@ pub struct EngineStatus {
|
||||
pub ngl: Option<i32>,
|
||||
pub uptime_secs: Option<u64>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user