feat(plugins): analyze plugin data in the built-in AI panel

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 15:04:33 +08:00
1 parent 7653ebfda6
commit 1566bb8a9f
33 files changed
+716 -216

No files matched your search

+3
View File
@@ -622,6 +622,9 @@ pub struct AiChatMessage {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiConversation {
/// Immutable plugin data retained independently of sent and pending messages.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_context: Option<serde_json::Value>,
pub id: String,
pub title: String,
pub connection_name: String,
+74 -15
View File
@@ -758,6 +758,7 @@ const SCHEMA_STATEMENTS: &[&str] = &[
database TEXT NOT NULL DEFAULT '',
messages_json TEXT NOT NULL DEFAULT '[]',
queued_input TEXT,
plugin_context_json TEXT,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)",
@@ -1223,10 +1224,9 @@ fn ensure_ai_configs_columns_sync(conn: &Connection) -> Result<(), String> {
Ok(())
}
/// Adds the queued-input column to `ai_conversations` for databases created
/// by earlier iterations of the uncommitted WIP, where the table predates it.
/// Adds conversation metadata columns to databases created before these fields.
fn ensure_ai_conversations_columns_sync(conn: &Connection) -> Result<(), String> {
const COLUMNS: &[(&str, &str)] = &[("queued_input", "TEXT")];
const COLUMNS: &[(&str, &str)] = &[("queued_input", "TEXT"), ("plugin_context_json", "TEXT")];
ensure_table_columns(conn, "ai_conversations", COLUMNS)
}
@@ -2883,8 +2883,8 @@ impl Storage {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT INTO ai_conversations \
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?) \
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
title = excluded.title, \
connection_name = excluded.connection_name, \
@@ -2892,7 +2892,7 @@ impl Storage {
messages_json = excluded.messages_json, \
queued_input = excluded.queued_input, \
created_at = excluded.created_at, \
updated_at = excluded.updated_at",
updated_at = excluded.updated_at, plugin_context_json = excluded.plugin_context_json",
params![
conv.id,
conv.title,
@@ -2901,7 +2901,8 @@ impl Storage {
messages_json,
conv.queued_input,
conv.created_at,
conv.updated_at
conv.updated_at,
conv.plugin_context.map(|value| value.to_string())
],
)
.map_err(|e| e.to_string())?;
@@ -2916,7 +2917,7 @@ impl Storage {
self.with_conn(|conn| {
let mut stmt = conn
.prepare(
"SELECT id, title, connection_name, database, messages_json, queued_input, created_at, updated_at \
"SELECT id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json \
FROM ai_conversations ORDER BY updated_at DESC",
)
.map_err(|e| e.to_string())?;
@@ -2934,6 +2935,7 @@ impl Storage {
queued_input: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
plugin_context: row.get::<_, Option<String>>(8)?.map(|json| serde_json::from_str(&json)).transpose().map_err(map_from_sql_err)?,
})
})
.map_err(|e| e.to_string())?;
@@ -3000,8 +3002,8 @@ impl Storage {
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute(
"INSERT INTO ai_conversations
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(id, title, connection_name, database, messages_json, queued_input, created_at, updated_at, plugin_context_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
connection_name = excluded.connection_name,
@@ -3009,7 +3011,7 @@ impl Storage {
messages_json = excluded.messages_json,
queued_input = excluded.queued_input,
created_at = excluded.created_at,
updated_at = excluded.updated_at",
updated_at = excluded.updated_at, plugin_context_json = excluded.plugin_context_json",
params![
conv.id,
conv.title,
@@ -3018,7 +3020,8 @@ impl Storage {
messages_json,
conv.queued_input,
conv.created_at,
conv.updated_at
conv.updated_at,
conv.plugin_context.map(|value| value.to_string())
],
)
.map_err(|e| e.to_string())?;
@@ -5210,8 +5213,8 @@ impl Storage {
self.with_conn(move |conn| {
conn.execute(
"INSERT OR IGNORE INTO ai_conversations \
(id, title, connection_name, database, messages_json, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
(id, title, connection_name, database, messages_json, created_at, updated_at, plugin_context_json) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
params![
conv.id,
conv.title,
@@ -5219,7 +5222,8 @@ impl Storage {
conv.database,
messages_json,
conv.created_at,
conv.updated_at
conv.updated_at,
conv.plugin_context.map(|value| value.to_string())
],
)
.map(|_| ())
@@ -5725,6 +5729,7 @@ mod tests {
fn ai_conversation(id: &str, updated_at: &str) -> AiConversation {
AiConversation {
plugin_context: None,
id: id.to_string(),
title: id.to_string(),
connection_name: "local".to_string(),
@@ -5934,6 +5939,60 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_upgrades_legacy_schema_for_plugin_context() {
let path = temp_db_path("ai-plugin-legacy-schema");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE ai_conversations (
id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', connection_name TEXT NOT NULL DEFAULT '',
database TEXT NOT NULL DEFAULT '', messages_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT ''
); INSERT INTO ai_conversations (id, title) VALUES ('legacy', 'SQL conversation');",
)
.unwrap();
drop(conn);
let storage = Storage::open(&path).await.unwrap();
let mut loaded = storage.load_ai_conversations().await.unwrap();
assert_eq!(loaded[0].title, "SQL conversation");
assert!(loaded[0].plugin_context.is_none());
loaded[0].plugin_context = Some(serde_json::json!({"data": {"snapshotId": "s1"}}));
storage.save_ai_conversation(&loaded[0]).await.unwrap();
assert_eq!(storage.load_ai_conversations().await.unwrap()[0].plugin_context, loaded[0].plugin_context);
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_retains_plugin_snapshot_without_a_database() {
let path = temp_db_path("ai-plugin-conversation");
let storage = Storage::open(&path).await.unwrap();
let mut conversation = ai_conversation("market-analysis", "0000");
conversation.database.clear();
let snapshot = serde_json::json!({
"pluginId": "market-watch", "pluginName": "Market Watch", "title": "AAPL",
"capturedAt": "2026-09-15T08:00:00Z", "data": { "price": 100, "currency": "USD" }
});
conversation.plugin_context = Some(snapshot.clone());
storage.save_ai_conversation(&conversation).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
assert_eq!(loaded[0].plugin_context, Some(snapshot.clone()));
assert!(loaded[0].database.is_empty());
// Existing database conversations remain compatible with the optional field.
let mut legacy_json = serde_json::to_value(&conversation).unwrap();
legacy_json.as_object_mut().unwrap().remove("pluginContext");
let legacy: AiConversation = serde_json::from_value(legacy_json).unwrap();
assert!(legacy.plugin_context.is_none());
// A first turn recovered from the desktop FIFO has no sent messages yet.
conversation.messages.clear();
let mut run = ai_run("queued-plugin", &conversation.id, AiRunStatus::PendingRecoverable, "0001");
run.pending_input = Some("analyse this snapshot".to_string());
storage.save_ai_run_state(&conversation, &run).await.unwrap();
let loaded = storage.load_ai_conversations().await.unwrap();
assert!(loaded[0].messages.is_empty());
assert_eq!(loaded[0].plugin_context, Some(snapshot));
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn ai_conversation_roundtrips_queued_input() {
let path = temp_db_path("ai-conversation-queued-input-roundtrip");
@@ -22,7 +22,7 @@ pub const PLUGIN_CONNECTION_CONNECT_METHOD: &str = "connection/connect";
pub const PLUGIN_CONNECTION_DISCONNECT_METHOD: &str = "connection/disconnect";
pub const PLUGIN_CONNECTION_ACTION_METHOD: &str = "connection/action";
pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] =
&["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.storage"];
&["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.storage", "host.ai"];
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
/// the sandbox CSP or enumerate large origin lists.