mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Migrate entity graph from SQLite to Neo4j backend API (#9)
* Migrate entity graph from local SQLite to remote Neo4j backend API Replace Tauri IPC invoke() calls with REST API calls to /api/entity-graph/ endpoints. EntityManager public API is unchanged so all consumers (AIProvider, EntityQuery, SkillRegistry) need no modifications. - Rewrite manager.ts to use apiClient instead of Tauri invoke() - Add Neo4j response types and conversion helpers in types.ts - Remove Rust entity_db.rs (607 lines) and rusqlite dependency - Remove 12 ai_entity_* commands from Tauri generate_handler Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add Git workflow guidelines to CLAUDE.md - Introduced a new section outlining the requirement for all pull requests to target the `develop` branch instead of `main`. This update aims to standardize the contribution process and improve collaboration among developers. * Enhance EntityManager with optimistic concurrency control for tag management - Introduced MAX_TAG_RETRIES constant to handle HTTP 409 Conflict errors during tag addition and removal. - Refactored addTag and removeTag methods to implement retry logic using If-Match headers for optimistic concurrency control. - Updated search and getByTag methods to utilize URLSearchParams for improved query handling. - Simplified entity fetching logic in getById and search methods to enhance performance and clarity. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
6e4eeecc0c
commit
d668a3a66a
@@ -197,6 +197,10 @@ Key updates from recent commits:
|
||||
- **Authentication**: Web-to-desktop handoff using `alphahuman://` scheme
|
||||
- **Connection Management**: Telegram MTProto and Socket.io integration
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- **PR target branch**: All pull requests should target the `develop` branch, not `main`.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **No localStorage**: Avoid `localStorage` and `sessionStorage`; use Redux (and persist) for app state. Remove any direct usage when working on affected code.
|
||||
|
||||
Generated
-62
@@ -43,18 +43,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -1146,18 +1134,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1691,9 +1667,6 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1701,15 +1674,6 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2285,17 +2249,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -3548,20 +3501,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
@@ -4303,7 +4242,6 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -47,7 +47,6 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# AI module dependencies
|
||||
rusqlite = { version = "0.31", features = ["bundled", "vtab"] }
|
||||
base64 = "0.22"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
//! SQLite entity database for the full platform graph.
|
||||
//!
|
||||
//! Stores metadata references (not full content) for all entities across
|
||||
//! the platform: chats, messages, emails, contacts, wallets, tokens,
|
||||
//! transactions. Enables cross-entity search and relationship traversal.
|
||||
//!
|
||||
//! Database location: `~/.alphahuman/entities.db`
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::encryption::get_data_dir;
|
||||
|
||||
/// Global database connection (initialized once).
|
||||
static ENTITY_DB: OnceCell<Mutex<rusqlite::Connection>> = OnceCell::new();
|
||||
|
||||
/// Core entity record.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Entity {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entity_type: String,
|
||||
pub source: String,
|
||||
pub source_id: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
/// JSON blob for type-specific fields.
|
||||
pub metadata: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Relationship between two entities.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EntityRelation {
|
||||
pub id: String,
|
||||
pub from_entity_id: String,
|
||||
pub to_entity_id: String,
|
||||
pub relation_type: String,
|
||||
pub metadata: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Tag attached to an entity.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EntityTag {
|
||||
pub entity_id: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// Search result from FTS5.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EntitySearchResult {
|
||||
pub id: String,
|
||||
pub entity_type: String,
|
||||
pub source: String,
|
||||
pub source_id: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub metadata: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Entity with its relations for traversal queries.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct EntityWithRelations {
|
||||
pub entity: Entity,
|
||||
pub relations: Vec<EntityRelation>,
|
||||
}
|
||||
|
||||
/// Get the entity database path (~/.alphahuman/entities.db).
|
||||
fn get_entity_db_path() -> Result<PathBuf, String> {
|
||||
Ok(get_data_dir()?.join("entities.db"))
|
||||
}
|
||||
|
||||
/// Initialize the entity database connection and create tables.
|
||||
fn init_entity_db() -> Result<rusqlite::Connection, String> {
|
||||
let db_path = get_entity_db_path()?;
|
||||
let conn =
|
||||
rusqlite::Connection::open(&db_path).map_err(|e| format!("Open entity database: {e}"))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")
|
||||
.map_err(|e| format!("WAL mode: {e}"))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"
|
||||
-- Core entity table (polymorphic)
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_id TEXT,
|
||||
title TEXT,
|
||||
summary TEXT,
|
||||
metadata TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(source, source_id)
|
||||
);
|
||||
|
||||
-- Relationships between entities
|
||||
CREATE TABLE IF NOT EXISTS entity_relations (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
to_entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(from_entity_id, to_entity_id, relation_type)
|
||||
);
|
||||
|
||||
-- Tags for flexible categorization
|
||||
CREATE TABLE IF NOT EXISTS entity_tags (
|
||||
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (entity_id, tag)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_source ON entities(source, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_updated ON entities(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_tag ON entity_tags(tag);
|
||||
|
||||
-- FTS for entity search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
||||
title, summary, id UNINDEXED, type UNINDEXED
|
||||
);
|
||||
|
||||
-- Enable foreign key constraints
|
||||
PRAGMA foreign_keys = ON;
|
||||
",
|
||||
)
|
||||
.map_err(|e| format!("Create entity tables: {e}"))?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Get or initialize the global entity database connection.
|
||||
fn get_entity_db() -> Result<&'static Mutex<rusqlite::Connection>, String> {
|
||||
ENTITY_DB.get_or_try_init(|| {
|
||||
let conn = init_entity_db()?;
|
||||
Ok::<Mutex<rusqlite::Connection>, String>(Mutex::new(conn))
|
||||
})
|
||||
}
|
||||
|
||||
// --- Tauri Commands ---
|
||||
|
||||
/// Initialize the entity database. Creates tables if they don't exist.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_db_init() -> Result<bool, String> {
|
||||
get_entity_db()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Upsert an entity (insert or update by id, or by source+source_id).
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_upsert(entity: Entity) -> Result<bool, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
// Delete existing FTS entry if entity exists
|
||||
conn.execute(
|
||||
"DELETE FROM entities_fts WHERE id = ?1",
|
||||
rusqlite::params![entity.id],
|
||||
)
|
||||
.map_err(|e| format!("Delete entity FTS: {e}"))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entities (id, type, source, source_id, title, summary, metadata, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params![
|
||||
entity.id,
|
||||
entity.entity_type,
|
||||
entity.source,
|
||||
entity.source_id,
|
||||
entity.title,
|
||||
entity.summary,
|
||||
entity.metadata,
|
||||
entity.created_at,
|
||||
entity.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Upsert entity: {e}"))?;
|
||||
|
||||
// Insert FTS entry
|
||||
conn.execute(
|
||||
"INSERT INTO entities_fts (title, summary, id, type) VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![
|
||||
entity.title.as_deref().unwrap_or(""),
|
||||
entity.summary.as_deref().unwrap_or(""),
|
||||
entity.id,
|
||||
entity.entity_type,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Insert entity FTS: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get an entity by ID.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_get(id: String) -> Result<Option<Entity>, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, type, source, source_id, title, summary, metadata, created_at, updated_at
|
||||
FROM entities WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| format!("Prepare: {e}"))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(rusqlite::params![id], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
entity_type: row.get(1)?,
|
||||
source: row.get(2)?,
|
||||
source_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
summary: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get an entity by source and source_id.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_get_by_source(
|
||||
source: String,
|
||||
source_id: String,
|
||||
) -> Result<Option<Entity>, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, type, source, source_id, title, summary, metadata, created_at, updated_at
|
||||
FROM entities WHERE source = ?1 AND source_id = ?2",
|
||||
)
|
||||
.map_err(|e| format!("Prepare: {e}"))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(rusqlite::params![source, source_id], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
entity_type: row.get(1)?,
|
||||
source: row.get(2)?,
|
||||
source_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
summary: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Full-text search on entities.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_search(
|
||||
query: String,
|
||||
types: Option<Vec<String>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<EntitySearchResult>, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
// Build query with optional type filter
|
||||
let base_sql = "
|
||||
SELECT e.id, e.type, e.source, e.source_id, e.title, e.summary, e.metadata,
|
||||
e.created_at, e.updated_at, rank AS score
|
||||
FROM entities_fts
|
||||
JOIN entities e ON e.id = entities_fts.id
|
||||
WHERE entities_fts MATCH ?1";
|
||||
|
||||
let sql = if let Some(ref type_list) = types {
|
||||
if type_list.is_empty() {
|
||||
format!("{base_sql} ORDER BY rank LIMIT ?2")
|
||||
} else {
|
||||
let placeholders: Vec<String> = type_list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("?{}", i + 3))
|
||||
.collect();
|
||||
format!(
|
||||
"{base_sql} AND e.type IN ({}) ORDER BY rank LIMIT ?2",
|
||||
placeholders.join(", ")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
format!("{base_sql} ORDER BY rank LIMIT ?2")
|
||||
};
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Prepare FTS: {e}"))?;
|
||||
|
||||
// Build params dynamically
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(query), Box::new(limit)];
|
||||
if let Some(ref type_list) = types {
|
||||
for t in type_list {
|
||||
params.push(Box::new(t.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let results = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
Ok(EntitySearchResult {
|
||||
id: row.get(0)?,
|
||||
entity_type: row.get(1)?,
|
||||
source: row.get(2)?,
|
||||
source_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
summary: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
score: row.get::<_, f64>(9)?.abs(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Entity FTS search: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// List entities by type with pagination.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_list(
|
||||
entity_type: String,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<Entity>, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, type, source, source_id, title, summary, metadata, created_at, updated_at
|
||||
FROM entities WHERE type = ?1 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3",
|
||||
)
|
||||
.map_err(|e| format!("Prepare: {e}"))?;
|
||||
|
||||
let results = stmt
|
||||
.query_map(rusqlite::params![entity_type, limit, offset], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
entity_type: row.get(1)?,
|
||||
source: row.get(2)?,
|
||||
source_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
summary: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("List entities: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Delete an entity and cascade to relations and tags.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_delete(id: String) -> Result<bool, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
// Delete FTS entry
|
||||
conn.execute(
|
||||
"DELETE FROM entities_fts WHERE id = ?1",
|
||||
rusqlite::params![id],
|
||||
)
|
||||
.map_err(|e| format!("Delete entity FTS: {e}"))?;
|
||||
|
||||
// Delete relations (both directions)
|
||||
conn.execute(
|
||||
"DELETE FROM entity_relations WHERE from_entity_id = ?1 OR to_entity_id = ?1",
|
||||
rusqlite::params![id],
|
||||
)
|
||||
.map_err(|e| format!("Delete relations: {e}"))?;
|
||||
|
||||
// Delete tags
|
||||
conn.execute(
|
||||
"DELETE FROM entity_tags WHERE entity_id = ?1",
|
||||
rusqlite::params![id],
|
||||
)
|
||||
.map_err(|e| format!("Delete tags: {e}"))?;
|
||||
|
||||
// Delete entity
|
||||
conn.execute("DELETE FROM entities WHERE id = ?1", rusqlite::params![id])
|
||||
.map_err(|e| format!("Delete entity: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Add a relationship between entities.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_add_relation(relation: EntityRelation) -> Result<bool, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entity_relations (id, from_entity_id, to_entity_id, relation_type, metadata, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
relation.id,
|
||||
relation.from_entity_id,
|
||||
relation.to_entity_id,
|
||||
relation.relation_type,
|
||||
relation.metadata,
|
||||
relation.created_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Add relation: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get related entities with optional direction and type filter.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_get_relations(
|
||||
entity_id: String,
|
||||
direction: Option<String>,
|
||||
relation_type: Option<String>,
|
||||
) -> Result<Vec<EntityRelation>, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
let dir = direction.as_deref().unwrap_or("both");
|
||||
|
||||
let sql = match (dir, relation_type.as_deref()) {
|
||||
("from", Some(rt)) => {
|
||||
format!(
|
||||
"SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at
|
||||
FROM entity_relations
|
||||
WHERE from_entity_id = ?1 AND relation_type = '{rt}'"
|
||||
)
|
||||
}
|
||||
("to", Some(rt)) => {
|
||||
format!(
|
||||
"SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at
|
||||
FROM entity_relations
|
||||
WHERE to_entity_id = ?1 AND relation_type = '{rt}'"
|
||||
)
|
||||
}
|
||||
("from", None) => {
|
||||
"SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at
|
||||
FROM entity_relations
|
||||
WHERE from_entity_id = ?1"
|
||||
.to_string()
|
||||
}
|
||||
("to", None) => {
|
||||
"SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at
|
||||
FROM entity_relations
|
||||
WHERE to_entity_id = ?1"
|
||||
.to_string()
|
||||
}
|
||||
(_, Some(rt)) => {
|
||||
format!(
|
||||
"SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at
|
||||
FROM entity_relations
|
||||
WHERE (from_entity_id = ?1 OR to_entity_id = ?1) AND relation_type = '{rt}'"
|
||||
)
|
||||
}
|
||||
_ => "SELECT id, from_entity_id, to_entity_id, relation_type, metadata, created_at
|
||||
FROM entity_relations
|
||||
WHERE from_entity_id = ?1 OR to_entity_id = ?1"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| format!("Prepare: {e}"))?;
|
||||
|
||||
let results = stmt
|
||||
.query_map(rusqlite::params![entity_id], |row| {
|
||||
Ok(EntityRelation {
|
||||
id: row.get(0)?,
|
||||
from_entity_id: row.get(1)?,
|
||||
to_entity_id: row.get(2)?,
|
||||
relation_type: row.get(3)?,
|
||||
metadata: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Get relations: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Tag an entity.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_add_tag(entity_id: String, tag: String) -> Result<bool, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?1, ?2)",
|
||||
rusqlite::params![entity_id, tag],
|
||||
)
|
||||
.map_err(|e| format!("Add tag: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove a tag from an entity.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_remove_tag(entity_id: String, tag: String) -> Result<bool, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM entity_tags WHERE entity_id = ?1 AND tag = ?2",
|
||||
rusqlite::params![entity_id, tag],
|
||||
)
|
||||
.map_err(|e| format!("Remove tag: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Find entities by tag, with optional type filter.
|
||||
#[tauri::command]
|
||||
pub async fn ai_entity_get_by_tag(
|
||||
tag: String,
|
||||
entity_type: Option<String>,
|
||||
) -> Result<Vec<Entity>, String> {
|
||||
let db = get_entity_db()?;
|
||||
let conn = db.lock();
|
||||
|
||||
let sql = if entity_type.is_some() {
|
||||
"SELECT e.id, e.type, e.source, e.source_id, e.title, e.summary, e.metadata, e.created_at, e.updated_at
|
||||
FROM entities e
|
||||
JOIN entity_tags t ON t.entity_id = e.id
|
||||
WHERE t.tag = ?1 AND e.type = ?2
|
||||
ORDER BY e.updated_at DESC"
|
||||
} else {
|
||||
"SELECT e.id, e.type, e.source, e.source_id, e.title, e.summary, e.metadata, e.created_at, e.updated_at
|
||||
FROM entities e
|
||||
JOIN entity_tags t ON t.entity_id = e.id
|
||||
WHERE t.tag = ?1
|
||||
ORDER BY e.updated_at DESC"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql).map_err(|e| format!("Prepare: {e}"))?;
|
||||
|
||||
let results = if let Some(ref et) = entity_type {
|
||||
stmt.query_map(rusqlite::params![tag, et], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
entity_type: row.get(1)?,
|
||||
source: row.get(2)?,
|
||||
source_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
summary: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Get by tag: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
} else {
|
||||
stmt.query_map(rusqlite::params![tag], |row| {
|
||||
Ok(Entity {
|
||||
id: row.get(0)?,
|
||||
entity_type: row.get(1)?,
|
||||
source: row.get(2)?,
|
||||
source_id: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
summary: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Get by tag: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
pub mod encryption;
|
||||
pub mod entity_db;
|
||||
pub mod memory_fs;
|
||||
pub mod sessions;
|
||||
|
||||
pub use encryption::*;
|
||||
pub use entity_db::*;
|
||||
pub use memory_fs::*;
|
||||
pub use sessions::*;
|
||||
|
||||
@@ -212,19 +212,6 @@ pub fn run() {
|
||||
ai_memory_get_cached_embedding,
|
||||
ai_memory_set_meta,
|
||||
ai_memory_get_meta,
|
||||
// AI entity database commands
|
||||
ai_entity_db_init,
|
||||
ai_entity_upsert,
|
||||
ai_entity_get,
|
||||
ai_entity_get_by_source,
|
||||
ai_entity_search,
|
||||
ai_entity_list,
|
||||
ai_entity_delete,
|
||||
ai_entity_add_relation,
|
||||
ai_entity_get_relations,
|
||||
ai_entity_add_tag,
|
||||
ai_entity_remove_tag,
|
||||
ai_entity_get_by_tag,
|
||||
// AI session commands
|
||||
ai_sessions_init,
|
||||
ai_sessions_load_index,
|
||||
|
||||
+180
-55
@@ -1,4 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { apiClient } from "../../../services/apiClient";
|
||||
import type {
|
||||
Entity,
|
||||
EntityRelation,
|
||||
@@ -6,77 +6,129 @@ import type {
|
||||
EntityType,
|
||||
EntitySource,
|
||||
RelationType,
|
||||
EntityRust,
|
||||
EntityRelationRust,
|
||||
EntitySearchResultRust,
|
||||
ApiResponse,
|
||||
Neo4jEntityNode,
|
||||
Neo4jRelationshipRecord,
|
||||
} from "./types";
|
||||
import {
|
||||
fromRustEntity,
|
||||
fromRustRelation,
|
||||
fromRustSearchResult,
|
||||
toRustEntity,
|
||||
toRustRelation,
|
||||
fromNeo4jEntity,
|
||||
fromNeo4jRelation,
|
||||
toNeo4jCreateBody,
|
||||
toNeo4jRelationBody,
|
||||
} from "./types";
|
||||
|
||||
const ENTITY_API = "/api/entity-graph";
|
||||
const MAX_TAG_RETRIES = 3;
|
||||
|
||||
/** Parse the properties JSON from a Neo4j entity node */
|
||||
function parseProps(props: string | null): Record<string, unknown> {
|
||||
if (!props) return {};
|
||||
try {
|
||||
return JSON.parse(props);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether an error represents an HTTP 409 Conflict */
|
||||
function isConflictError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const error = (err as Record<string, unknown>).error;
|
||||
return typeof error === "string" && error.includes("409");
|
||||
}
|
||||
|
||||
/**
|
||||
* EntityManager wraps Tauri commands for the entity relationship database.
|
||||
* EntityManager wraps REST API calls to the Neo4j-backed entity graph.
|
||||
*
|
||||
* Provides a typed interface for CRUD operations on entities, relations,
|
||||
* and tags in the platform graph.
|
||||
* and tags in the platform graph via the backend API.
|
||||
*/
|
||||
export class EntityManager {
|
||||
private initialized = false;
|
||||
|
||||
/** Initialize the entity database */
|
||||
/** Initialize the entity manager (no-op for remote backend) */
|
||||
async init(): Promise<void> {
|
||||
await invoke("ai_entity_db_init");
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/** Ensure the database is initialized */
|
||||
/** Ensure the manager is initialized */
|
||||
private async ensureInit(): Promise<void> {
|
||||
if (!this.initialized) await this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert an entity (insert or update).
|
||||
* If an entity with the same id or same source+sourceId exists, it's updated.
|
||||
* Upsert an entity (create via POST).
|
||||
* The backend handles insert-or-update semantics.
|
||||
*/
|
||||
async upsert(entity: Entity): Promise<void> {
|
||||
await this.ensureInit();
|
||||
await invoke("ai_entity_upsert", { entity: toRustEntity(entity) });
|
||||
const body = toNeo4jCreateBody(entity);
|
||||
await apiClient.post<ApiResponse<Neo4jEntityNode>>(
|
||||
`${ENTITY_API}/entities`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get an entity by ID */
|
||||
async get(id: string): Promise<Entity | null> {
|
||||
await this.ensureInit();
|
||||
const result = await invoke<EntityRust | null>("ai_entity_get", { id });
|
||||
return result ? fromRustEntity(result) : null;
|
||||
try {
|
||||
const resp = await apiClient.get<ApiResponse<Neo4jEntityNode>>(
|
||||
`${ENTITY_API}/entities/${id}`,
|
||||
);
|
||||
return fromNeo4jEntity(resp.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get an entity by source system reference */
|
||||
async getBySource(source: EntitySource, sourceId: string): Promise<Entity | null> {
|
||||
async getBySource(
|
||||
source: EntitySource,
|
||||
sourceId: string,
|
||||
): Promise<Entity | null> {
|
||||
await this.ensureInit();
|
||||
const result = await invoke<EntityRust | null>("ai_entity_get_by_source", {
|
||||
source,
|
||||
sourceId,
|
||||
});
|
||||
return result ? fromRustEntity(result) : null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
source,
|
||||
sourceId,
|
||||
limit: "1",
|
||||
});
|
||||
const resp = await apiClient.get<
|
||||
ApiResponse<{ entities: Neo4jEntityNode[]; count: number }>
|
||||
>(`${ENTITY_API}/entities?${params}`);
|
||||
const node = resp.data.entities[0];
|
||||
return node ? fromNeo4jEntity(node) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full-text search on entities */
|
||||
/** Search entities via server-side query */
|
||||
async search(
|
||||
query: string,
|
||||
types?: EntityType[],
|
||||
limit = 20,
|
||||
): Promise<EntitySearchResult[]> {
|
||||
await this.ensureInit();
|
||||
const results = await invoke<EntitySearchResultRust[]>("ai_entity_search", {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
types: types ?? null,
|
||||
limit,
|
||||
limit: String(limit),
|
||||
});
|
||||
return results.map(fromRustSearchResult);
|
||||
if (types && types.length > 0) {
|
||||
params.set("types", types.join(","));
|
||||
}
|
||||
const resp = await apiClient.get<
|
||||
ApiResponse<{
|
||||
entities: (Neo4jEntityNode & { score?: number })[];
|
||||
count: number;
|
||||
}>
|
||||
>(`${ENTITY_API}/entities?${params}`);
|
||||
|
||||
return resp.data.entities.map((node) => ({
|
||||
...fromNeo4jEntity(node),
|
||||
score: node.score ?? 1.0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** List entities by type with pagination */
|
||||
@@ -86,26 +138,32 @@ export class EntityManager {
|
||||
limit = 50,
|
||||
): Promise<Entity[]> {
|
||||
await this.ensureInit();
|
||||
const results = await invoke<EntityRust[]>("ai_entity_list", {
|
||||
entityType,
|
||||
offset,
|
||||
limit,
|
||||
const params = new URLSearchParams({
|
||||
type: entityType,
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return results.map(fromRustEntity);
|
||||
const resp = await apiClient.get<
|
||||
ApiResponse<{ entities: Neo4jEntityNode[]; count: number }>
|
||||
>(`${ENTITY_API}/entities?${params}`);
|
||||
|
||||
return resp.data.entities.map(fromNeo4jEntity);
|
||||
}
|
||||
|
||||
/** Delete an entity and cascade relations/tags */
|
||||
/** Delete an entity (soft-delete on backend) */
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.ensureInit();
|
||||
await invoke("ai_entity_delete", { id });
|
||||
await apiClient.delete(`${ENTITY_API}/entities/${id}`);
|
||||
}
|
||||
|
||||
/** Add a relationship between entities */
|
||||
async addRelation(relation: EntityRelation): Promise<void> {
|
||||
await this.ensureInit();
|
||||
await invoke("ai_entity_add_relation", {
|
||||
relation: toRustRelation(relation),
|
||||
});
|
||||
const body = toNeo4jRelationBody(relation);
|
||||
await apiClient.post<ApiResponse<Neo4jRelationshipRecord>>(
|
||||
`${ENTITY_API}/relationships`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,33 +178,100 @@ export class EntityManager {
|
||||
relationType?: RelationType,
|
||||
): Promise<EntityRelation[]> {
|
||||
await this.ensureInit();
|
||||
const results = await invoke<EntityRelationRust[]>("ai_entity_get_relations", {
|
||||
entityId,
|
||||
direction: direction ?? null,
|
||||
relationType: relationType ?? null,
|
||||
});
|
||||
return results.map(fromRustRelation);
|
||||
const resp = await apiClient.get<
|
||||
ApiResponse<{ relationships: Neo4jRelationshipRecord[] }>
|
||||
>(`${ENTITY_API}/entities/${entityId}/relationships`);
|
||||
|
||||
let relations = resp.data.relationships.map(fromNeo4jRelation);
|
||||
|
||||
// Filter by direction
|
||||
const dir = direction ?? "both";
|
||||
if (dir === "from") {
|
||||
relations = relations.filter((r) => r.fromEntityId === entityId);
|
||||
} else if (dir === "to") {
|
||||
relations = relations.filter((r) => r.toEntityId === entityId);
|
||||
}
|
||||
|
||||
// Filter by relation type
|
||||
if (relationType) {
|
||||
relations = relations.filter((r) => r.relationType === relationType);
|
||||
}
|
||||
|
||||
return relations;
|
||||
}
|
||||
|
||||
/** Tag an entity */
|
||||
/**
|
||||
* Tag an entity with optimistic concurrency control.
|
||||
* Uses If-Match with the entity's updatedAt to detect concurrent writes,
|
||||
* retrying up to MAX_TAG_RETRIES times on 409 Conflict.
|
||||
*/
|
||||
async addTag(entityId: string, tag: string): Promise<void> {
|
||||
await this.ensureInit();
|
||||
await invoke("ai_entity_add_tag", { entityId, tag });
|
||||
for (let attempt = 0; attempt < MAX_TAG_RETRIES; attempt++) {
|
||||
const resp = await apiClient.get<ApiResponse<Neo4jEntityNode>>(
|
||||
`${ENTITY_API}/entities/${entityId}`,
|
||||
);
|
||||
const node = resp.data;
|
||||
const props = parseProps(node.properties);
|
||||
const tags: string[] = Array.isArray(props.tags) ? props.tags : [];
|
||||
if (!tags.includes(tag)) {
|
||||
tags.push(tag);
|
||||
}
|
||||
props.tags = tags;
|
||||
|
||||
try {
|
||||
await apiClient.put(
|
||||
`${ENTITY_API}/entities/${entityId}`,
|
||||
{ properties: JSON.stringify(props) },
|
||||
{ headers: { "If-Match": node.updatedAt } },
|
||||
);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (!isConflictError(err) || attempt === MAX_TAG_RETRIES - 1) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a tag from an entity */
|
||||
/**
|
||||
* Remove a tag with optimistic concurrency control.
|
||||
* Uses If-Match with the entity's updatedAt to detect concurrent writes,
|
||||
* retrying up to MAX_TAG_RETRIES times on 409 Conflict.
|
||||
*/
|
||||
async removeTag(entityId: string, tag: string): Promise<void> {
|
||||
await this.ensureInit();
|
||||
await invoke("ai_entity_remove_tag", { entityId, tag });
|
||||
for (let attempt = 0; attempt < MAX_TAG_RETRIES; attempt++) {
|
||||
const resp = await apiClient.get<ApiResponse<Neo4jEntityNode>>(
|
||||
`${ENTITY_API}/entities/${entityId}`,
|
||||
);
|
||||
const node = resp.data;
|
||||
const props = parseProps(node.properties);
|
||||
const tags: string[] = Array.isArray(props.tags) ? props.tags : [];
|
||||
props.tags = tags.filter((t) => t !== tag);
|
||||
|
||||
try {
|
||||
await apiClient.put(
|
||||
`${ENTITY_API}/entities/${entityId}`,
|
||||
{ properties: JSON.stringify(props) },
|
||||
{ headers: { "If-Match": node.updatedAt } },
|
||||
);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (!isConflictError(err) || attempt === MAX_TAG_RETRIES - 1) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Find entities by tag, optionally filtered by type */
|
||||
async getByTag(tag: string, entityType?: EntityType): Promise<Entity[]> {
|
||||
await this.ensureInit();
|
||||
const results = await invoke<EntityRust[]>("ai_entity_get_by_tag", {
|
||||
tag,
|
||||
entityType: entityType ?? null,
|
||||
});
|
||||
return results.map(fromRustEntity);
|
||||
const params = new URLSearchParams({ tag, limit: "500" });
|
||||
if (entityType) {
|
||||
params.set("type", entityType);
|
||||
}
|
||||
const resp = await apiClient.get<
|
||||
ApiResponse<{ entities: Neo4jEntityNode[]; count: number }>
|
||||
>(`${ENTITY_API}/entities?${params}`);
|
||||
|
||||
return resp.data.entities.map(fromNeo4jEntity);
|
||||
}
|
||||
}
|
||||
|
||||
+116
-93
@@ -20,10 +20,9 @@ export type RelationType =
|
||||
| "traded"
|
||||
| "replied_to";
|
||||
|
||||
/** Core entity record */
|
||||
/** Core entity record (frontend domain type) */
|
||||
export interface Entity {
|
||||
id: string;
|
||||
/** Mapped from Rust `type` field */
|
||||
type: EntityType;
|
||||
source: EntitySource;
|
||||
sourceId: string | null;
|
||||
@@ -35,19 +34,6 @@ export interface Entity {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Entity as returned from Rust IPC (snake_case) */
|
||||
export interface EntityRust {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
source: string;
|
||||
source_id: string | null;
|
||||
title: string | null;
|
||||
summary: string | null;
|
||||
metadata: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** Relationship between two entities */
|
||||
export interface EntityRelation {
|
||||
id: string;
|
||||
@@ -58,32 +44,133 @@ export interface EntityRelation {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Relation as returned from Rust IPC (snake_case) */
|
||||
export interface EntityRelationRust {
|
||||
id: string;
|
||||
from_entity_id: string;
|
||||
to_entity_id: string;
|
||||
relation_type: string;
|
||||
metadata: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** Tag attached to an entity */
|
||||
export interface EntityTag {
|
||||
entityId: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** Search result from entity FTS */
|
||||
/** Search result from entity search */
|
||||
export interface EntitySearchResult extends Entity {
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Entity search result from Rust IPC */
|
||||
export interface EntitySearchResultRust extends EntityRust {
|
||||
score: number;
|
||||
// --- Neo4j backend response types ---
|
||||
|
||||
/** Entity node as returned from the Neo4j backend API */
|
||||
export interface Neo4jEntityNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string | null;
|
||||
properties: string | null;
|
||||
confidence: number;
|
||||
isActive: boolean;
|
||||
ownerId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Relationship record as returned from the Neo4j backend API */
|
||||
export interface Neo4jRelationshipRecord {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
type: string;
|
||||
properties: string | null;
|
||||
weight: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Wrapper for all API responses */
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// --- Conversion helpers ---
|
||||
|
||||
/** Parse a properties JSON string, returning an empty object on failure */
|
||||
function parseProperties(props: string | null): Record<string, unknown> {
|
||||
if (!props) return {};
|
||||
try {
|
||||
return JSON.parse(props);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a Neo4j entity node to the frontend Entity type */
|
||||
export function fromNeo4jEntity(node: Neo4jEntityNode): Entity {
|
||||
const props = parseProperties(node.properties);
|
||||
|
||||
const source = (props.source as string) ?? "manual";
|
||||
const sourceId = (props.sourceId as string) ?? null;
|
||||
|
||||
// Build metadata from remaining properties (excluding source, sourceId, tags)
|
||||
const { source: _s, sourceId: _sid, tags: _t, ...rest } = props;
|
||||
const metadata = Object.keys(rest).length > 0 ? JSON.stringify(rest) : null;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.type as EntityType,
|
||||
source: source as EntitySource,
|
||||
sourceId,
|
||||
title: node.name,
|
||||
summary: node.description,
|
||||
metadata,
|
||||
createdAt: new Date(node.createdAt).getTime(),
|
||||
updatedAt: new Date(node.updatedAt).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a request body for POST/PUT to the Neo4j entity API */
|
||||
export function toNeo4jCreateBody(entity: Entity): Record<string, unknown> {
|
||||
// Merge source, sourceId, tags, and any existing metadata into properties
|
||||
const existingMeta = parseProperties(entity.metadata);
|
||||
const properties: Record<string, unknown> = {
|
||||
...existingMeta,
|
||||
source: entity.source,
|
||||
};
|
||||
if (entity.sourceId) {
|
||||
properties.sourceId = entity.sourceId;
|
||||
}
|
||||
|
||||
return {
|
||||
name: entity.title ?? "",
|
||||
type: entity.type,
|
||||
description: entity.summary ?? undefined,
|
||||
properties: JSON.stringify(properties),
|
||||
confidence: 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Neo4j relationship record to the frontend EntityRelation type */
|
||||
export function fromNeo4jRelation(r: Neo4jRelationshipRecord): EntityRelation {
|
||||
return {
|
||||
id: r.id,
|
||||
fromEntityId: r.sourceId,
|
||||
toEntityId: r.targetId,
|
||||
relationType: r.type as RelationType,
|
||||
metadata: r.properties,
|
||||
createdAt: new Date(r.createdAt).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a request body for POST to the Neo4j relationship API */
|
||||
export function toNeo4jRelationBody(
|
||||
relation: EntityRelation,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
sourceId: relation.fromEntityId,
|
||||
targetId: relation.toEntityId,
|
||||
type: relation.relationType,
|
||||
properties: relation.metadata ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Metadata interfaces ---
|
||||
|
||||
/** Contact metadata */
|
||||
export interface ContactMetadata {
|
||||
username?: string;
|
||||
@@ -134,67 +221,3 @@ export interface TransactionMetadata {
|
||||
value?: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
// --- Conversion helpers ---
|
||||
|
||||
/** Convert Rust entity to TypeScript entity */
|
||||
export function fromRustEntity(e: EntityRust): Entity {
|
||||
return {
|
||||
id: e.id,
|
||||
type: e.entity_type as EntityType,
|
||||
source: e.source as EntitySource,
|
||||
sourceId: e.source_id,
|
||||
title: e.title,
|
||||
summary: e.summary,
|
||||
metadata: e.metadata,
|
||||
createdAt: e.created_at,
|
||||
updatedAt: e.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert TypeScript entity to Rust entity */
|
||||
export function toRustEntity(e: Entity): EntityRust {
|
||||
return {
|
||||
id: e.id,
|
||||
entity_type: e.type,
|
||||
source: e.source,
|
||||
source_id: e.sourceId,
|
||||
title: e.title,
|
||||
summary: e.summary,
|
||||
metadata: e.metadata,
|
||||
created_at: e.createdAt,
|
||||
updated_at: e.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert Rust relation to TypeScript relation */
|
||||
export function fromRustRelation(r: EntityRelationRust): EntityRelation {
|
||||
return {
|
||||
id: r.id,
|
||||
fromEntityId: r.from_entity_id,
|
||||
toEntityId: r.to_entity_id,
|
||||
relationType: r.relation_type as RelationType,
|
||||
metadata: r.metadata,
|
||||
createdAt: r.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert TypeScript relation to Rust relation */
|
||||
export function toRustRelation(r: EntityRelation): EntityRelationRust {
|
||||
return {
|
||||
id: r.id,
|
||||
from_entity_id: r.fromEntityId,
|
||||
to_entity_id: r.toEntityId,
|
||||
relation_type: r.relationType,
|
||||
metadata: r.metadata,
|
||||
created_at: r.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert Rust search result to TypeScript search result */
|
||||
export function fromRustSearchResult(r: EntitySearchResultRust): EntitySearchResult {
|
||||
return {
|
||||
...fromRustEntity(r),
|
||||
score: r.score,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
* Modules:
|
||||
* - **constitution/** — Agent safety & compliance framework
|
||||
* - **memory/** — JSON file-based index + vector search memory storage
|
||||
* - **entities/** — SQLite entity relationship database for platform graph
|
||||
* - **entities/** — Remote Neo4j entity graph via backend API
|
||||
* - **prompts/** — Modular system prompt construction
|
||||
* - **sessions/** — JSONL session transcripts with compaction
|
||||
* - **skills/** — Skill loading, registry, lifecycle hooks, and installation
|
||||
|
||||
Reference in New Issue
Block a user