feat: chat stats, regenerate with version history, edit & resubmit, image/file attachments

This commit is contained in:
Xianren Studio
2026-08-13 18:18:51 +08:00
parent 4718e41433
commit cd77f75066
12 changed files with 1156 additions and 199 deletions
+3
View File
@@ -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)
}
+14
View File
@@ -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
View File
@@ -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)?,
})
}
+1 -1
View File
@@ -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};
+18 -4
View File
@@ -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) => {
+2 -3
View File
@@ -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
}
+55 -2
View File
@@ -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>,
}