feat: chat stats, regenerate with version history, edit & resubmit, image/file attachments
This commit is contained in:
@@ -82,6 +82,9 @@ fn open_db(path: &Path) -> Result<Connection> {
|
||||
ensure_column(&conn, "models", "base_url", "TEXT")?;
|
||||
ensure_column(&conn, "models", "api_key", "TEXT")?;
|
||||
ensure_column(&conn, "models", "api_model", "TEXT")?;
|
||||
ensure_column(&conn, "messages", "elapsed_ms", "INTEGER")?;
|
||||
ensure_column(&conn, "messages", "first_token_ms", "INTEGER")?;
|
||||
ensure_column(&conn, "messages", "images_json", "TEXT NOT NULL DEFAULT '[]'")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,5 +38,19 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
content TEXT NOT NULL,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
elapsed_ms INTEGER,
|
||||
first_token_ms INTEGER,
|
||||
images_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
tokens_out INTEGER,
|
||||
elapsed_ms INTEGER,
|
||||
first_token_ms INTEGER,
|
||||
seq INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
+172
-6
@@ -20,6 +20,21 @@ pub struct Message {
|
||||
pub content: String,
|
||||
pub tokens_in: Option<i64>,
|
||||
pub tokens_out: Option<i64>,
|
||||
pub elapsed_ms: Option<i64>,
|
||||
pub first_token_ms: Option<i64>,
|
||||
pub images: Vec<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MessageVersion {
|
||||
pub id: String,
|
||||
pub message_id: String,
|
||||
pub content: String,
|
||||
pub tokens_out: Option<i64>,
|
||||
pub elapsed_ms: Option<i64>,
|
||||
pub first_token_ms: Option<i64>,
|
||||
pub seq: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -82,20 +97,34 @@ pub fn add_message(
|
||||
content: &str,
|
||||
tokens_in: Option<i64>,
|
||||
tokens_out: Option<i64>,
|
||||
elapsed_ms: Option<i64>,
|
||||
first_token_ms: Option<i64>,
|
||||
images: &[String],
|
||||
) -> Result<String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let images_json = serde_json::to_string(images).unwrap_or_else(|_| "[]".to_string());
|
||||
db.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, tokens_in, tokens_out)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![id, conversation_id, role, content, tokens_in, tokens_out],
|
||||
"INSERT INTO messages (id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
id,
|
||||
conversation_id,
|
||||
role,
|
||||
content,
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
elapsed_ms,
|
||||
first_token_ms,
|
||||
images_json
|
||||
],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn list_messages(db: &Connection, conversation_id: &str) -> Result<Vec<Message>> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, created_at
|
||||
FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
|
||||
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at
|
||||
FROM messages WHERE conversation_id = ?1 ORDER BY rowid ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![conversation_id], row_to_message)?;
|
||||
let mut out = Vec::new();
|
||||
@@ -105,6 +134,127 @@ pub fn list_messages(db: &Connection, conversation_id: &str) -> Result<Vec<Messa
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn get_message(db: &Connection, id: &str) -> Result<Option<Message>> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at
|
||||
FROM messages WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], row_to_message)?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_message_with_rowid(db: &Connection, id: &str) -> Result<Option<(i64, Message)>> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT rowid, id, conversation_id, role, content, tokens_in, tokens_out, elapsed_ms, first_token_ms, images_json, created_at
|
||||
FROM messages WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], |row| {
|
||||
let rowid: i64 = row.get(0)?;
|
||||
let msg = row_to_message(row)?;
|
||||
Ok((rowid, msg))
|
||||
})?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_message_content(db: &Connection, id: &str, content: &str) -> Result<()> {
|
||||
db.execute(
|
||||
"UPDATE messages SET content = ?1 WHERE id = ?2",
|
||||
params![content, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_message_stats(
|
||||
db: &Connection,
|
||||
id: &str,
|
||||
tokens_out: Option<i64>,
|
||||
elapsed_ms: Option<i64>,
|
||||
first_token_ms: Option<i64>,
|
||||
) -> Result<()> {
|
||||
db.execute(
|
||||
"UPDATE messages SET tokens_out = ?1, elapsed_ms = ?2, first_token_ms = ?3 WHERE id = ?4",
|
||||
params![tokens_out, elapsed_ms, first_token_ms, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除某条消息(含)之后的所有消息。
|
||||
pub fn delete_messages_after(db: &Connection, conversation_id: &str, rowid: i64) -> Result<()> {
|
||||
db.execute(
|
||||
"DELETE FROM messages WHERE conversation_id = ?1 AND rowid >= ?2",
|
||||
params![conversation_id, rowid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_message_version(
|
||||
db: &Connection,
|
||||
message_id: &str,
|
||||
content: &str,
|
||||
tokens_out: Option<i64>,
|
||||
elapsed_ms: Option<i64>,
|
||||
first_token_ms: Option<i64>,
|
||||
) -> Result<String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let seq: i64 = db.query_row(
|
||||
"SELECT COUNT(*) FROM message_versions WHERE message_id = ?1",
|
||||
params![message_id],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? + 1;
|
||||
db.execute(
|
||||
"INSERT INTO message_versions (id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn list_message_versions(db: &Connection, message_id: &str) -> Result<Vec<MessageVersion>> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq, created_at
|
||||
FROM message_versions WHERE message_id = ?1 ORDER BY seq ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![message_id], row_to_version)?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn get_message_version(db: &Connection, version_id: &str) -> Result<Option<MessageVersion>> {
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT id, message_id, content, tokens_out, elapsed_ms, first_token_ms, seq, created_at
|
||||
FROM message_versions WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![version_id], row_to_version)?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_message_version(
|
||||
db: &Connection,
|
||||
message_id: &str,
|
||||
content: &str,
|
||||
tokens_out: Option<i64>,
|
||||
elapsed_ms: Option<i64>,
|
||||
first_token_ms: Option<i64>,
|
||||
) -> Result<()> {
|
||||
db.execute(
|
||||
"UPDATE messages SET content = ?1, tokens_out = ?2, elapsed_ms = ?3, first_token_ms = ?4 WHERE id = ?5",
|
||||
params![content, tokens_out, elapsed_ms, first_token_ms, message_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn row_to_conversation(row: &rusqlite::Row<'_>) -> rusqlite::Result<Conversation> {
|
||||
Ok(Conversation {
|
||||
id: row.get(0)?,
|
||||
@@ -117,6 +267,7 @@ fn row_to_conversation(row: &rusqlite::Row<'_>) -> rusqlite::Result<Conversation
|
||||
}
|
||||
|
||||
fn row_to_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<Message> {
|
||||
let images_json: String = row.get(8)?;
|
||||
Ok(Message {
|
||||
id: row.get(0)?,
|
||||
conversation_id: row.get(1)?,
|
||||
@@ -124,7 +275,22 @@ fn row_to_message(row: &rusqlite::Row<'_>) -> rusqlite::Result<Message> {
|
||||
content: row.get(3)?,
|
||||
tokens_in: row.get(4)?,
|
||||
tokens_out: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
elapsed_ms: row.get(6)?,
|
||||
first_token_ms: row.get(7)?,
|
||||
images: serde_json::from_str(&images_json).unwrap_or_default(),
|
||||
created_at: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_version(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageVersion> {
|
||||
Ok(MessageVersion {
|
||||
id: row.get(0)?,
|
||||
message_id: row.get(1)?,
|
||||
content: row.get(2)?,
|
||||
tokens_out: row.get(3)?,
|
||||
elapsed_ms: row.get(4)?,
|
||||
first_token_ms: row.get(5)?,
|
||||
seq: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
@@ -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