feat(acp): introduce threads (#8344)
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
@@ -3,7 +3,9 @@ mod name_builder;
|
||||
mod registry;
|
||||
|
||||
pub use model::{CanonicalModel, Limit, Modalities, Modality, Pricing};
|
||||
pub use name_builder::{canonical_name, map_to_canonical_model, strip_version_suffix};
|
||||
pub use name_builder::{
|
||||
canonical_name, map_provider_name, map_to_canonical_model, strip_version_suffix,
|
||||
};
|
||||
pub use registry::CanonicalModelRegistry;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -21,6 +23,47 @@ impl ModelMapping {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return recommended model names for a provider using only the bundled canonical registry.
|
||||
///
|
||||
/// This avoids network calls by looking up all known models for the provider,
|
||||
/// filtering to text-input + tool-calling models, and sorting by release date.
|
||||
/// The returned names are the canonical short names (e.g. "claude-3.5-sonnet").
|
||||
///
|
||||
/// TODO: This trades speed for correctness — the canonical registry may not perfectly
|
||||
/// match what the provider API returns (new models not yet in the registry, deprecated
|
||||
/// models still listed, or locally-installed models for providers like Ollama). Consider
|
||||
/// whether to reconcile with a live API call in the background.
|
||||
pub fn recommended_models_from_registry(provider: &str) -> Vec<String> {
|
||||
let registry = match CanonicalModelRegistry::bundled() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let registry_provider = map_provider_name(provider);
|
||||
let all = registry.get_all_models_for_provider(registry_provider);
|
||||
|
||||
let mut models_with_dates: Vec<(String, Option<String>)> = all
|
||||
.iter()
|
||||
.filter(|m| m.modalities.input.contains(&Modality::Text) && m.tool_call)
|
||||
.filter_map(|m| {
|
||||
let (_, name) = m.id.split_once('/')?;
|
||||
Some((name.to_string(), m.release_date.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
models_with_dates.sort_by(|a, b| match (&a.1, &b.1) {
|
||||
(Some(date_a), Some(date_b)) => date_b.cmp(date_a),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
|
||||
models_with_dates
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option<CanonicalModel> {
|
||||
let registry = CanonicalModelRegistry::bundled().ok()?;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ fn is_meta_provider(provider: &str) -> bool {
|
||||
matches!(provider, "databricks" | "tetrate" | "bedrock" | "azure")
|
||||
}
|
||||
|
||||
fn map_provider_name(provider: &str) -> &str {
|
||||
pub fn map_provider_name(provider: &str) -> &str {
|
||||
match provider {
|
||||
// Goose provider names that differ from models.dev names
|
||||
"xai" => "x-ai",
|
||||
|
||||
@@ -3,6 +3,7 @@ mod diagnostics;
|
||||
pub mod extension_data;
|
||||
mod legacy;
|
||||
pub mod session_manager;
|
||||
pub mod thread_manager;
|
||||
|
||||
pub use diagnostics::{
|
||||
config_path, generate_diagnostics, get_system_info, latest_llm_log_path,
|
||||
@@ -12,3 +13,4 @@ pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState,
|
||||
pub use session_manager::{
|
||||
Session, SessionInsights, SessionManager, SessionType, SessionUpdateBuilder,
|
||||
};
|
||||
pub use thread_manager::{Thread, ThreadManager, ThreadMetadata};
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::{Arc, LazyLock};
|
||||
use tracing::{info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub const CURRENT_SCHEMA_VERSION: i32 = 9;
|
||||
pub const CURRENT_SCHEMA_VERSION: i32 = 10;
|
||||
pub const SESSIONS_FOLDER: &str = "sessions";
|
||||
pub const DB_NAME: &str = "sessions.db";
|
||||
|
||||
@@ -81,6 +81,8 @@ pub struct Session {
|
||||
pub model_config: Option<ModelConfig>,
|
||||
#[serde(default)]
|
||||
pub goose_mode: GooseMode,
|
||||
#[serde(default)]
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SessionUpdateBuilder<'a> {
|
||||
@@ -103,6 +105,7 @@ pub struct SessionUpdateBuilder<'a> {
|
||||
provider_name: Option<Option<String>>,
|
||||
model_config: Option<Option<ModelConfig>>,
|
||||
goose_mode: Option<GooseMode>,
|
||||
thread_id: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Debug)]
|
||||
@@ -134,6 +137,7 @@ impl<'a> SessionUpdateBuilder<'a> {
|
||||
provider_name: None,
|
||||
model_config: None,
|
||||
goose_mode: None,
|
||||
thread_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +245,11 @@ impl<'a> SessionUpdateBuilder<'a> {
|
||||
self.goose_mode = Some(mode);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn thread_id(mut self, thread_id: Option<String>) -> Self {
|
||||
self.thread_id = Some(thread_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionManager {
|
||||
@@ -361,7 +370,22 @@ impl SessionManager {
|
||||
|
||||
if user_message_count <= MSG_COUNT_FOR_SESSION_NAME_GENERATION {
|
||||
let name = provider.generate_session_name(id, &conversation).await?;
|
||||
self.update(id).system_generated_name(name).apply().await
|
||||
self.update(id)
|
||||
.system_generated_name(name.clone())
|
||||
.apply()
|
||||
.await?;
|
||||
|
||||
// Also update the thread name so ACP clients see it via session/list.
|
||||
if let Some(ref thread_id) = session.thread_id {
|
||||
let thread_mgr = super::thread_manager::ThreadManager::new(self.storage.clone());
|
||||
let thread = thread_mgr.get_thread(thread_id).await?;
|
||||
if !thread.user_set_name {
|
||||
thread_mgr
|
||||
.update_thread(thread_id, Some(name), Some(false), None)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -407,7 +431,7 @@ pub struct SessionStorage {
|
||||
session_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn role_to_string(role: &Role) -> &'static str {
|
||||
pub(crate) fn role_to_string(role: &Role) -> &'static str {
|
||||
match role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
@@ -439,6 +463,7 @@ impl Default for Session {
|
||||
provider_name: None,
|
||||
model_config: None,
|
||||
goose_mode: GooseMode::default(),
|
||||
thread_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +533,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_default(),
|
||||
thread_id: row.try_get("thread_id").ok().flatten(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -537,7 +563,7 @@ impl SessionStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async fn pool(&self) -> Result<&Pool<Sqlite>> {
|
||||
pub(crate) async fn pool(&self) -> Result<&Pool<Sqlite>> {
|
||||
self.initialized
|
||||
.get_or_try_init(|| async {
|
||||
let schema_exists = sqlx::query_scalar::<_, bool>(
|
||||
@@ -607,7 +633,8 @@ impl SessionStorage {
|
||||
user_recipe_values_json TEXT,
|
||||
provider_name TEXT,
|
||||
model_config_json TEXT,
|
||||
goose_mode TEXT NOT NULL DEFAULT 'auto'
|
||||
goose_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
thread_id TEXT
|
||||
)
|
||||
"#,
|
||||
)
|
||||
@@ -647,6 +674,48 @@ impl SessionStorage {
|
||||
sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sessions_thread ON sessions(thread_id)")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT 'New Chat',
|
||||
user_set_name BOOLEAN DEFAULT FALSE,
|
||||
working_dir TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at TIMESTAMP,
|
||||
metadata_json TEXT DEFAULT '{}'
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS thread_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL REFERENCES threads(id),
|
||||
session_id TEXT,
|
||||
message_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content_json TEXT NOT NULL,
|
||||
created_timestamp INTEGER NOT NULL,
|
||||
metadata_json TEXT DEFAULT '{}'
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_thread_messages_thread ON thread_messages(thread_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_message_id ON thread_messages(message_id)")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -938,6 +1007,59 @@ impl SessionStorage {
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
10 => {
|
||||
// Check if thread_id column already exists (e.g. fresh schema)
|
||||
let has_thread_id = sqlx::query_scalar::<_, i32>(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'thread_id'",
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?
|
||||
> 0;
|
||||
if !has_thread_id {
|
||||
sqlx::query("ALTER TABLE sessions ADD COLUMN thread_id TEXT")
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_thread ON sessions(thread_id)",
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT 'New Chat',
|
||||
user_set_name BOOLEAN DEFAULT FALSE,
|
||||
working_dir TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at TIMESTAMP,
|
||||
metadata_json TEXT DEFAULT '{}'
|
||||
)",
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS thread_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL REFERENCES threads(id),
|
||||
session_id TEXT,
|
||||
message_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content_json TEXT NOT NULL,
|
||||
created_timestamp INTEGER NOT NULL,
|
||||
metadata_json TEXT DEFAULT '{}'
|
||||
)",
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_thread ON thread_messages(thread_id)")
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_thread_messages_message_id ON thread_messages(message_id)")
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Unknown migration version: {}", version);
|
||||
}
|
||||
@@ -999,7 +1121,7 @@ impl SessionStorage {
|
||||
total_tokens, input_tokens, output_tokens,
|
||||
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
|
||||
schedule_id, recipe_json, user_recipe_values_json,
|
||||
provider_name, model_config_json, goose_mode
|
||||
provider_name, model_config_json, goose_mode, thread_id
|
||||
FROM sessions
|
||||
WHERE id = ?
|
||||
"#,
|
||||
@@ -1063,6 +1185,7 @@ impl SessionStorage {
|
||||
add_update!(builder.provider_name, "provider_name");
|
||||
add_update!(builder.model_config, "model_config_json");
|
||||
add_update!(builder.goose_mode, "goose_mode");
|
||||
add_update!(builder.thread_id, "thread_id");
|
||||
|
||||
if updates.is_empty() {
|
||||
return Ok(());
|
||||
@@ -1131,6 +1254,9 @@ impl SessionStorage {
|
||||
if let Some(goose_mode) = builder.goose_mode {
|
||||
q = q.bind(goose_mode.to_string());
|
||||
}
|
||||
if let Some(thread_id) = builder.thread_id {
|
||||
q = q.bind(thread_id);
|
||||
}
|
||||
|
||||
let pool = self.pool().await?;
|
||||
let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
@@ -1282,10 +1408,10 @@ impl SessionStorage {
|
||||
s.total_tokens, s.input_tokens, s.output_tokens,
|
||||
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
|
||||
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
|
||||
s.provider_name, s.model_config_json, s.goose_mode,
|
||||
s.provider_name, s.model_config_json, s.goose_mode, s.thread_id,
|
||||
COUNT(m.id) as message_count
|
||||
FROM sessions s
|
||||
INNER JOIN messages m ON s.id = m.session_id
|
||||
LEFT JOIN messages m ON s.id = m.session_id
|
||||
{}
|
||||
GROUP BY s.id
|
||||
ORDER BY s.updated_at DESC
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
use super::session_manager::{role_to_string, SessionStorage};
|
||||
use crate::conversation::message::Message;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rmcp::model::Role;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub user_set_name: bool,
|
||||
pub working_dir: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub archived_at: Option<DateTime<Utc>>,
|
||||
pub metadata: ThreadMetadata,
|
||||
#[serde(default)]
|
||||
pub current_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub message_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ThreadMetadata {
|
||||
#[serde(default)]
|
||||
pub persona_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct ThreadManager {
|
||||
storage: Arc<SessionStorage>,
|
||||
}
|
||||
|
||||
const THREAD_SELECT: &str = "\
|
||||
SELECT t.id, t.name, t.user_set_name, t.working_dir, t.created_at, t.updated_at, \
|
||||
t.archived_at, t.metadata_json, \
|
||||
(SELECT s.id FROM sessions s WHERE s.thread_id = t.id ORDER BY s.created_at DESC LIMIT 1) as current_session_id, \
|
||||
(SELECT COUNT(*) FROM thread_messages WHERE thread_id = t.id) as message_count \
|
||||
FROM threads t";
|
||||
|
||||
type ThreadRow = (
|
||||
String,
|
||||
String,
|
||||
bool,
|
||||
Option<String>,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
i64,
|
||||
);
|
||||
|
||||
fn thread_from_row(
|
||||
(
|
||||
id,
|
||||
name,
|
||||
user_set_name,
|
||||
working_dir,
|
||||
created_at,
|
||||
updated_at,
|
||||
archived_at_str,
|
||||
metadata_json,
|
||||
current_session_id,
|
||||
message_count,
|
||||
): ThreadRow,
|
||||
) -> Result<Thread> {
|
||||
let metadata: ThreadMetadata = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||
let archived_at = archived_at_str.as_deref().and_then(|s| s.parse().ok());
|
||||
Ok(Thread {
|
||||
id,
|
||||
name,
|
||||
user_set_name,
|
||||
working_dir,
|
||||
created_at: created_at.parse().unwrap_or_else(|_| Utc::now()),
|
||||
updated_at: updated_at.parse().unwrap_or_else(|_| Utc::now()),
|
||||
archived_at,
|
||||
metadata,
|
||||
current_session_id,
|
||||
message_count,
|
||||
})
|
||||
}
|
||||
|
||||
impl ThreadManager {
|
||||
pub fn new(storage: Arc<SessionStorage>) -> Self {
|
||||
Self { storage }
|
||||
}
|
||||
|
||||
pub async fn create_thread(
|
||||
&self,
|
||||
name: Option<String>,
|
||||
metadata: Option<ThreadMetadata>,
|
||||
working_dir: Option<String>,
|
||||
) -> Result<Thread> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let name = name.unwrap_or_else(|| "New Chat".to_string());
|
||||
let meta = metadata.unwrap_or_default();
|
||||
let metadata_json = serde_json::to_string(&meta)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO threads (id, name, user_set_name, working_dir, metadata_json) VALUES (?, ?, FALSE, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&name)
|
||||
.bind(&working_dir)
|
||||
.bind(&metadata_json)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
self.get_thread(&id).await
|
||||
}
|
||||
|
||||
pub async fn get_thread(&self, id: &str) -> Result<Thread> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let sql = format!("{} WHERE t.id = ?", THREAD_SELECT);
|
||||
let row = sqlx::query_as::<_, ThreadRow>(&sql)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
thread_from_row(row)
|
||||
}
|
||||
|
||||
pub async fn update_thread(
|
||||
&self,
|
||||
id: &str,
|
||||
name: Option<String>,
|
||||
user_set_name: Option<bool>,
|
||||
metadata: Option<ThreadMetadata>,
|
||||
) -> Result<Thread> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let mut sets = Vec::new();
|
||||
|
||||
if name.is_some() {
|
||||
sets.push("name = ?");
|
||||
sets.push("user_set_name = ?");
|
||||
}
|
||||
if metadata.is_some() {
|
||||
sets.push("metadata_json = ?");
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
let sql = format!(
|
||||
"UPDATE threads SET {}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
sets.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(ref n) = name {
|
||||
q = q.bind(n);
|
||||
q = q.bind(user_set_name.unwrap_or(true));
|
||||
}
|
||||
if let Some(ref meta) = metadata {
|
||||
q = q.bind(serde_json::to_string(meta)?);
|
||||
}
|
||||
q = q.bind(id);
|
||||
q.execute(pool).await?;
|
||||
}
|
||||
|
||||
self.get_thread(id).await
|
||||
}
|
||||
|
||||
pub async fn list_threads(&self, include_archived: bool) -> Result<Vec<Thread>> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let sql = if include_archived {
|
||||
format!("{} ORDER BY t.updated_at DESC", THREAD_SELECT)
|
||||
} else {
|
||||
format!(
|
||||
"{} WHERE t.archived_at IS NULL ORDER BY t.updated_at DESC",
|
||||
THREAD_SELECT
|
||||
)
|
||||
};
|
||||
let rows = sqlx::query_as::<_, ThreadRow>(&sql).fetch_all(pool).await?;
|
||||
|
||||
rows.into_iter().map(thread_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn archive_thread(&self, id: &str) -> Result<Thread> {
|
||||
let pool = self.storage.pool().await?;
|
||||
sqlx::query("UPDATE threads SET archived_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
self.get_thread(id).await
|
||||
}
|
||||
|
||||
pub async fn unarchive_thread(&self, id: &str) -> Result<Thread> {
|
||||
let pool = self.storage.pool().await?;
|
||||
sqlx::query(
|
||||
"UPDATE threads SET archived_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
self.get_thread(id).await
|
||||
}
|
||||
|
||||
pub async fn update_metadata(
|
||||
&self,
|
||||
id: &str,
|
||||
f: impl FnOnce(&mut ThreadMetadata),
|
||||
) -> Result<Thread> {
|
||||
let thread = self.get_thread(id).await?;
|
||||
let mut meta = thread.metadata;
|
||||
f(&mut meta);
|
||||
self.update_thread(id, None, None, Some(meta)).await
|
||||
}
|
||||
|
||||
pub async fn update_working_dir(&self, id: &str, working_dir: &str) -> Result<()> {
|
||||
let pool = self.storage.pool().await?;
|
||||
sqlx::query(
|
||||
"UPDATE threads SET working_dir = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
)
|
||||
.bind(working_dir)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_thread(&self, id: &str) -> Result<()> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM thread_messages WHERE thread_id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE thread_id = ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM sessions WHERE thread_id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM threads WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn append_message(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
session_id: Option<&str>,
|
||||
message: &Message,
|
||||
) -> Result<Message> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let role_str = role_to_string(&message.role);
|
||||
let metadata_json = serde_json::to_string(&message.metadata)?;
|
||||
|
||||
// When the incoming message is text-only, try to coalesce it with the
|
||||
// last stored row if that row has the same role and is also text-only.
|
||||
// This avoids storing one row per streaming token while keeping the UI
|
||||
// streaming path unchanged (callers still forward every chunk).
|
||||
if message.has_only_text_content() && !message.content.is_empty() {
|
||||
let new_text = message.as_concat_text();
|
||||
|
||||
let maybe_last = sqlx::query_as::<_, (i64, String, String, String, String)>(
|
||||
"SELECT id, message_id, role, content_json, metadata_json \
|
||||
FROM thread_messages \
|
||||
WHERE thread_id = ? \
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((
|
||||
row_id,
|
||||
existing_msg_id,
|
||||
last_role,
|
||||
last_content_json,
|
||||
last_metadata_json,
|
||||
)) = maybe_last
|
||||
{
|
||||
if last_role == role_str
|
||||
&& last_metadata_json == metadata_json
|
||||
&& is_text_only_json(&last_content_json)
|
||||
{
|
||||
// Append text into the existing row's single text element.
|
||||
let updated_json = append_text_json(&last_content_json, &new_text)?;
|
||||
sqlx::query("UPDATE thread_messages SET content_json = ? WHERE id = ?")
|
||||
.bind(&updated_json)
|
||||
.bind(row_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(thread_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let mut stored = message.clone();
|
||||
stored.id = Some(existing_msg_id);
|
||||
return Ok(stored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default path: insert a new row.
|
||||
let content_json = serde_json::to_string(&message.content)?;
|
||||
|
||||
let message_id = message
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("tmsg_{}", uuid::Uuid::new_v4()));
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO thread_messages (thread_id, session_id, message_id, role, content_json, created_timestamp, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(session_id)
|
||||
.bind(&message_id)
|
||||
.bind(role_str)
|
||||
.bind(&content_json)
|
||||
.bind(message.created)
|
||||
.bind(&metadata_json)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE threads SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(thread_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let mut stored = message.clone();
|
||||
stored.id = Some(message_id);
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
pub async fn fork_thread(&self, source_thread_id: &str) -> Result<Thread> {
|
||||
let source = self.get_thread(source_thread_id).await?;
|
||||
let pool = self.storage.pool().await?;
|
||||
|
||||
let new_id = uuid::Uuid::new_v4().to_string();
|
||||
let name = format!("Fork of {}", source.name);
|
||||
let metadata_json = serde_json::to_string(&source.metadata)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO threads (id, name, user_set_name, working_dir, metadata_json) VALUES (?, ?, FALSE, ?, ?)",
|
||||
)
|
||||
.bind(&new_id)
|
||||
.bind(&name)
|
||||
.bind(&source.working_dir)
|
||||
.bind(&metadata_json)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Copy all thread messages
|
||||
sqlx::query(
|
||||
"INSERT INTO thread_messages (thread_id, session_id, message_id, role, content_json, created_timestamp, metadata_json) \
|
||||
SELECT ?, session_id, 'tmsg_' || hex(randomblob(16)), role, content_json, created_timestamp, metadata_json \
|
||||
FROM thread_messages WHERE thread_id = ? ORDER BY id ASC",
|
||||
)
|
||||
.bind(&new_id)
|
||||
.bind(source_thread_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
self.get_thread(&new_id).await
|
||||
}
|
||||
|
||||
pub async fn list_messages(&self, thread_id: &str) -> Result<Vec<Message>> {
|
||||
let pool = self.storage.pool().await?;
|
||||
let rows = sqlx::query_as::<_, (Option<String>, String, Option<String>, String, i64, String)>(
|
||||
"SELECT message_id, role, session_id, content_json, created_timestamp, metadata_json FROM thread_messages WHERE thread_id = ? ORDER BY id ASC",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for (message_id, role_str, _session_id, content_json, created_timestamp, metadata_json) in
|
||||
rows
|
||||
{
|
||||
let role = match role_str.as_str() {
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
_ => continue,
|
||||
};
|
||||
let content = serde_json::from_str(&content_json)?;
|
||||
let metadata = serde_json::from_str(&metadata_json).unwrap_or_default();
|
||||
|
||||
let mut msg = Message::new(role, created_timestamp, content);
|
||||
msg.metadata = metadata;
|
||||
if let Some(id) = message_id {
|
||||
msg = msg.with_id(id);
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a `content_json` string represents a single text-only element.
|
||||
/// Avoids a full deserialize by inspecting the JSON structure directly.
|
||||
fn is_text_only_json(content_json: &str) -> bool {
|
||||
let Ok(items) = serde_json::from_str::<Vec<serde_json::Value>>(content_json) else {
|
||||
return false;
|
||||
};
|
||||
items.len() == 1
|
||||
&& items[0].get("type").and_then(|v| v.as_str()) == Some("text")
|
||||
&& items[0].get("text").is_some()
|
||||
}
|
||||
|
||||
/// Append `new_text` to the single text element in a text-only `content_json` array.
|
||||
fn append_text_json(content_json: &str, new_text: &str) -> anyhow::Result<String> {
|
||||
let mut items: Vec<serde_json::Value> = serde_json::from_str(content_json)?;
|
||||
if let Some(text_val) = items.get_mut(0).and_then(|v| v.get_mut("text")) {
|
||||
let existing = text_val.as_str().unwrap_or("");
|
||||
*text_val = serde_json::Value::String(format!("{}{}", existing, new_text));
|
||||
}
|
||||
Ok(serde_json::to_string(&items)?)
|
||||
}
|
||||
Reference in New Issue
Block a user