mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
* chore: update AlphaHuman version to 0.49.3 and configure updater plugin in tauri.conf.json - Bumped the AlphaHuman package version in Cargo.lock to 0.49.3. - Added updater configuration in tauri.conf.json to enable automatic updates with specified endpoints. * refactor: rename AlphaHuman to OpenHuman across the codebase - Updated all instances of "AlphaHuman" to "OpenHuman" in comments, tooltips, and constants to reflect the new branding. - Adjusted relevant documentation and prompts to ensure consistency with the new name. * refactor: update documentation and configurations to reflect OpenHuman branding - Replaced all instances of "AlphaHuman" with "OpenHuman" in documentation, comments, and configuration files to ensure consistency with the new branding. - Updated deep link URLs and related authentication flows to use the new "openhuman://" scheme. - Adjusted paths and references in the skills system and other related files to align with the new project name.te * refactor: standardize OpenHuman references and update configurations - Replaced all instances of "AlphaHuman" with "OpenHuman" across documentation, comments, and configuration files to maintain branding consistency. - Updated URLs and paths to reflect the new "openhuman://" scheme. - Adjusted environment variable names and related settings to align with the new project identity. - Enhanced documentation for clarity and accuracy regarding the OpenHuman framework.r * chore: update subproject commit reference in skills directory * refactor: update backend URL to reflect new service domain - Changed default backend URL from "https://api.openhuman.xyz" to "https://api.tinyhumans.ai" in both JavaScript and Rust configuration files. - Ensured consistency across the codebase regarding the new backend service endpoint. * feat: introduce identity and migration modules for OpenHuman - Added a new identity module to support AIEOS v1.1 JSON format, including structures for identity, psychology, linguistics, motivations, capabilities, physicality, history, and interests. - Implemented a migration module to facilitate data migration from OpenClaw memory, including SQLite and Markdown sources, with detailed reporting on migration statistics and warnings. - Established utility functions for handling multimodal content and image processing within the OpenHuman framework. - Enhanced the agent system with new dispatcher and classifier functionalities to improve tool management and message classification. * chore: remove Android project files and configurations - Deleted various Android project files including .editorconfig, .gitignore, build.gradle.kts, gradle.properties, and others to clean up the project structure. - Removed all related resources, layouts, and source files from the Android app directory to streamline the codebase. - This cleanup is part of a larger effort to refactor and simplify the project structure. * refactor: update login flow and remove Telegram integration - Removed the TelegramLoginButton component and its references from the OAuthLoginSection, streamlining the login options. - Updated the AppRoutes to remove the login route, reflecting changes in the authentication flow. - Enhanced the RotatingTetrahedronCanvas component with improved geometry and lighting effects for better visual presentation. - Adjusted the TypewriterGreeting component's styling for consistency. - Cleaned up the Welcome page to integrate the OAuthLoginSection directly, improving user experience. * chore: update subproject commit reference in skills directory * chore: update test configurations and improve test assertions - Modified test scripts in package.json to use a specific Vitest configuration file for consistency. - Updated assertions in loader tests to ensure loading durations are non-negative. - Enhanced tool loading tests to clarify expected behavior regarding localStorage and cache management. - Adjusted agent tool registry tests to improve error handling and ensure accurate statistics. - Refined device detection tests to reflect updated fallback URLs. * fix: enhance parameter formatting and remove unused components - Updated the `formatParameters` function to handle cases where schema properties are empty, returning a more informative response. - Deleted the `DownloadScreen` component and associated device detection utilities to streamline the codebase and remove unused functionality. - Adjusted tests to reflect changes in the tool loading and agent tool registry, ensuring accuracy in assertions. * chore: simplify Vitest configuration by removing unused include patterns - Updated the Vitest configuration to remove unnecessary test file patterns, streamlining the test setup for better clarity and maintainability. * refactor: update paths and comments for AI configuration and file watching - Modified Vite configuration to ignore only the `src-tauri` directory. - Updated logging messages to reflect the correct path for writing AI configuration files. - Adjusted fetch calls in the file watcher to use the new path for `TOOLS.md`. - Revised comments and logic in Rust code to clarify the handling of AI configuration file paths, including legacy fallback options. * chore: remove unused updater secrets from GitHub Actions workflow - Deleted UPDATER_GIST_URL and UPDATER_GIST_ID environment variables from the package-and-publish workflow, streamlining the configuration. * chore: comment out Vitest thresholds for clarity - Commented out the thresholds section in the Vitest configuration to improve clarity and maintainability, as it is currently not in use. * ran formatter * chore: update updater public key in tauri configuration - Replaced the existing public key in the updater plugin configuration with a new value to ensure proper functionality and security. * chore: update ESLint configuration and refactor components - Added `localStorage` and `sessionStorage` as readonly globals in ESLint configuration for better linting support. - Removed unused imports from `SkillsPanel.tsx` to clean up the code. - Changed the type of `watcherInterval` in `file-watcher.ts` for improved type safety. - Refactored toast management logic in `Intelligence.tsx` to enhance clarity and maintainability. - Simplified import statements in `IntelligenceProvider.tsx` for consistency. - Streamlined object property shorthand in `agentToolRegistry.ts` for cleaner code. * refactor: improve error handling and type safety in Intelligence component - Enhanced toast notification logic to defer state updates, preventing potential issues with setState in effects. - Updated the source filter dispatch to use a more specific type for improved type safety. * refactor: enhance type safety across various components and services - Updated type definitions from `any` to `unknown` in multiple files to improve type safety and prevent potential runtime errors. - Refactored state management in `TauriCommandsPanel` to use more specific types. - Adjusted context and parameters in several interfaces to ensure consistent typing. - Added ESLint directive to `polyfills.ts` for intentional global assignments. - Streamlined type handling in utility functions and API responses for better clarity and maintainability. * refactor: streamline import statements and improve code clarity - Consolidated import statements in `agentToolRegistry.ts` and `intelligenceSlice.ts` for better readability. - Simplified the `createTestStore` function in `test-utils.tsx` to enhance code conciseness. - Cleaned up the `isExecutionStepProgressEvent` function in `intelligence-chat-api.ts` for improved clarity and maintainability.
461 lines
14 KiB
Rust
461 lines
14 KiB
Rust
//! Filesystem-based memory index for AI memory storage.
|
||
//!
|
||
//! Replaces the SQLite memory_db with JSON files under ~/.openhuman/index/.
|
||
//! Chunk files, file metadata, embedding cache, and KV metadata are all
|
||
//! stored as readable JSON. All operations are exposed as Tauri commands
|
||
//! with the same signatures as the former SQLite implementation.
|
||
|
||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||
use parking_lot::Mutex;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
|
||
use super::encryption::get_data_dir;
|
||
|
||
/// Lazy-initialized index directory state.
|
||
static INDEX_INIT: once_cell::sync::OnceCell<Mutex<()>> = once_cell::sync::OnceCell::new();
|
||
|
||
/// File metadata tracked in the index.
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
pub struct FileRecord {
|
||
pub path: String,
|
||
pub source: String,
|
||
pub hash: String,
|
||
pub mtime: i64,
|
||
pub size: i64,
|
||
}
|
||
|
||
/// A chunk of content with optional embedding.
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
pub struct ChunkRecord {
|
||
pub id: String,
|
||
pub path: String,
|
||
pub source: String,
|
||
pub start_line: i64,
|
||
pub end_line: i64,
|
||
pub hash: String,
|
||
pub model: String,
|
||
pub text: String,
|
||
/// Embedding stored as base64-encoded Float32Array bytes.
|
||
pub embedding: Option<Vec<u8>>,
|
||
pub updated_at: i64,
|
||
}
|
||
|
||
/// Chunk as stored in JSON (embedding is base64 string for readability).
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
struct ChunkJson {
|
||
id: String,
|
||
path: String,
|
||
source: String,
|
||
start_line: i64,
|
||
end_line: i64,
|
||
hash: String,
|
||
model: String,
|
||
text: String,
|
||
/// Base64-encoded Float32Array bytes, or null.
|
||
embedding_b64: Option<String>,
|
||
updated_at: i64,
|
||
}
|
||
|
||
impl From<ChunkRecord> for ChunkJson {
|
||
fn from(c: ChunkRecord) -> Self {
|
||
ChunkJson {
|
||
id: c.id,
|
||
path: c.path,
|
||
source: c.source,
|
||
start_line: c.start_line,
|
||
end_line: c.end_line,
|
||
hash: c.hash,
|
||
model: c.model,
|
||
text: c.text,
|
||
embedding_b64: c.embedding.map(|e| BASE64.encode(&e)),
|
||
updated_at: c.updated_at,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<ChunkJson> for ChunkRecord {
|
||
fn from(c: ChunkJson) -> Self {
|
||
ChunkRecord {
|
||
id: c.id,
|
||
path: c.path,
|
||
source: c.source,
|
||
start_line: c.start_line,
|
||
end_line: c.end_line,
|
||
hash: c.hash,
|
||
model: c.model,
|
||
text: c.text,
|
||
embedding: c.embedding_b64.and_then(|b| BASE64.decode(&b).ok()),
|
||
updated_at: c.updated_at,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Search result with relevance score.
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
pub struct SearchResult {
|
||
pub chunk_id: String,
|
||
pub path: String,
|
||
pub source: String,
|
||
pub text: String,
|
||
pub score: f64,
|
||
pub start_line: i64,
|
||
pub end_line: i64,
|
||
}
|
||
|
||
/// Embedding cache entry.
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
pub struct EmbeddingCacheEntry {
|
||
pub provider: String,
|
||
pub model: String,
|
||
pub hash: String,
|
||
pub embedding: Vec<u8>,
|
||
pub dims: Option<i64>,
|
||
pub updated_at: i64,
|
||
}
|
||
|
||
/// Embedding cache entry as stored in JSON.
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
struct EmbeddingCacheJson {
|
||
provider: String,
|
||
model: String,
|
||
hash: String,
|
||
embedding_b64: String,
|
||
dims: Option<i64>,
|
||
updated_at: i64,
|
||
}
|
||
|
||
impl From<EmbeddingCacheEntry> for EmbeddingCacheJson {
|
||
fn from(e: EmbeddingCacheEntry) -> Self {
|
||
EmbeddingCacheJson {
|
||
provider: e.provider,
|
||
model: e.model,
|
||
hash: e.hash,
|
||
embedding_b64: BASE64.encode(&e.embedding),
|
||
dims: e.dims,
|
||
updated_at: e.updated_at,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<EmbeddingCacheJson> for EmbeddingCacheEntry {
|
||
fn from(e: EmbeddingCacheJson) -> Self {
|
||
EmbeddingCacheEntry {
|
||
provider: e.provider,
|
||
model: e.model,
|
||
hash: e.hash,
|
||
embedding: BASE64.decode(&e.embedding_b64).unwrap_or_default(),
|
||
dims: e.dims,
|
||
updated_at: e.updated_at,
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Path helpers ---
|
||
|
||
/// Get the index directory (~/.openhuman/index/).
|
||
fn get_index_dir() -> Result<PathBuf, String> {
|
||
Ok(get_data_dir()?.join("index"))
|
||
}
|
||
|
||
/// Get the chunks subdirectory (~/.openhuman/index/chunks/).
|
||
fn get_chunks_dir() -> Result<PathBuf, String> {
|
||
Ok(get_index_dir()?.join("chunks"))
|
||
}
|
||
|
||
/// Encode a file path into a safe filename for chunk storage.
|
||
/// `/` → `--`, e.g. `memory/foo.md` → `memory--foo.md.json`.
|
||
fn encode_chunk_filename(path: &str) -> String {
|
||
format!("{}.json", path.replace('/', "--"))
|
||
}
|
||
|
||
/// Get path to files.json.
|
||
fn files_json_path() -> Result<PathBuf, String> {
|
||
Ok(get_index_dir()?.join("files.json"))
|
||
}
|
||
|
||
/// Get path to meta.json.
|
||
fn meta_json_path() -> Result<PathBuf, String> {
|
||
Ok(get_index_dir()?.join("meta.json"))
|
||
}
|
||
|
||
/// Get path to embedding-cache.json.
|
||
fn embedding_cache_path() -> Result<PathBuf, String> {
|
||
Ok(get_index_dir()?.join("embedding-cache.json"))
|
||
}
|
||
|
||
/// Get path to a chunk file for a given memory file path.
|
||
fn chunk_file_path(path: &str) -> Result<PathBuf, String> {
|
||
Ok(get_chunks_dir()?.join(encode_chunk_filename(path)))
|
||
}
|
||
|
||
// --- JSON file I/O helpers ---
|
||
|
||
/// Read and deserialize a JSON file, returning default if not found.
|
||
fn read_json<T: serde::de::DeserializeOwned + Default>(path: &PathBuf) -> Result<T, String> {
|
||
match std::fs::read_to_string(path) {
|
||
Ok(content) => {
|
||
if content.trim().is_empty() {
|
||
return Ok(T::default());
|
||
}
|
||
serde_json::from_str(&content).map_err(|e| format!("Parse {}: {e}", path.display()))
|
||
}
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
|
||
Err(e) => Err(format!("Read {}: {e}", path.display())),
|
||
}
|
||
}
|
||
|
||
/// Serialize and write a JSON file atomically.
|
||
fn write_json<T: Serialize>(path: &PathBuf, data: &T) -> Result<(), String> {
|
||
let content = serde_json::to_string_pretty(data).map_err(|e| format!("Serialize: {e}"))?;
|
||
// Write to temp file then rename for atomicity
|
||
let tmp = path.with_extension("tmp");
|
||
std::fs::write(&tmp, &content).map_err(|e| format!("Write {}: {e}", tmp.display()))?;
|
||
std::fs::rename(&tmp, path).map_err(|e| format!("Rename: {e}"))?;
|
||
Ok(())
|
||
}
|
||
|
||
// --- Tauri Commands ---
|
||
|
||
/// Initialize the memory index. Creates directories and empty JSON files.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_init() -> Result<bool, String> {
|
||
INDEX_INIT.get_or_try_init(|| {
|
||
let index_dir = get_index_dir()?;
|
||
let chunks_dir = get_chunks_dir()?;
|
||
std::fs::create_dir_all(&index_dir).map_err(|e| format!("Create index dir: {e}"))?;
|
||
std::fs::create_dir_all(&chunks_dir).map_err(|e| format!("Create chunks dir: {e}"))?;
|
||
|
||
// Create empty JSON files if they don't exist
|
||
let files_path = files_json_path()?;
|
||
if !files_path.exists() {
|
||
let empty: HashMap<String, FileRecord> = HashMap::new();
|
||
write_json(&files_path, &empty)?;
|
||
}
|
||
|
||
let meta_path = meta_json_path()?;
|
||
if !meta_path.exists() {
|
||
let empty: HashMap<String, String> = HashMap::new();
|
||
write_json(&meta_path, &empty)?;
|
||
}
|
||
|
||
let cache_path = embedding_cache_path()?;
|
||
if !cache_path.exists() {
|
||
let empty: Vec<EmbeddingCacheJson> = Vec::new();
|
||
write_json(&cache_path, &empty)?;
|
||
}
|
||
|
||
Ok::<Mutex<()>, String>(Mutex::new(()))
|
||
})?;
|
||
Ok(true)
|
||
}
|
||
|
||
/// Upsert a file record in files.json.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_upsert_file(file: FileRecord) -> Result<bool, String> {
|
||
let path = files_json_path()?;
|
||
let mut files: HashMap<String, FileRecord> = read_json(&path)?;
|
||
files.insert(file.path.clone(), file);
|
||
write_json(&path, &files)?;
|
||
Ok(true)
|
||
}
|
||
|
||
/// Get a file record by path.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_get_file(path: String) -> Result<Option<FileRecord>, String> {
|
||
let files_path = files_json_path()?;
|
||
let files: HashMap<String, FileRecord> = read_json(&files_path)?;
|
||
Ok(files.get(&path).cloned())
|
||
}
|
||
|
||
/// Upsert a chunk record into the appropriate chunk file.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_upsert_chunk(chunk: ChunkRecord) -> Result<bool, String> {
|
||
let chunk_path = chunk_file_path(&chunk.path)?;
|
||
let mut chunks: Vec<ChunkJson> = read_json(&chunk_path)?;
|
||
|
||
// Remove existing chunk with same ID
|
||
chunks.retain(|c| c.id != chunk.id);
|
||
|
||
// Add the new/updated chunk
|
||
chunks.push(ChunkJson::from(chunk));
|
||
|
||
write_json(&chunk_path, &chunks)?;
|
||
Ok(true)
|
||
}
|
||
|
||
/// Delete chunks by file path (removes the entire chunk file).
|
||
#[tauri::command]
|
||
pub async fn ai_memory_delete_chunks_by_path(path: String) -> Result<i64, String> {
|
||
let chunk_path = chunk_file_path(&path)?;
|
||
if chunk_path.exists() {
|
||
// Count chunks before deleting
|
||
let chunks: Vec<ChunkJson> = read_json(&chunk_path)?;
|
||
let count = chunks.len() as i64;
|
||
std::fs::remove_file(&chunk_path).map_err(|e| format!("Delete chunk file: {e}"))?;
|
||
Ok(count)
|
||
} else {
|
||
Ok(0)
|
||
}
|
||
}
|
||
|
||
/// Keyword search across all chunk files.
|
||
///
|
||
/// Algorithm (replaces FTS5 BM25):
|
||
/// 1. Lowercase query, split into whitespace-separated terms
|
||
/// 2. For each chunk across all files: lowercase text, count how many query terms
|
||
/// appear as substrings
|
||
/// 3. Score = matched_terms / total_terms (0.0–1.0), skip chunks with score 0
|
||
/// 4. Sort descending, return top `limit` results
|
||
#[tauri::command]
|
||
pub async fn ai_memory_fts_search(query: String, limit: i64) -> Result<Vec<SearchResult>, String> {
|
||
let chunks_dir = get_chunks_dir()?;
|
||
|
||
let query_lower = query.to_lowercase();
|
||
let terms: Vec<&str> = query_lower.split_whitespace().collect();
|
||
if terms.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
let total_terms = terms.len() as f64;
|
||
|
||
let mut results: Vec<SearchResult> = Vec::new();
|
||
|
||
// Read all chunk files in the chunks directory
|
||
let entries = match std::fs::read_dir(&chunks_dir) {
|
||
Ok(e) => e,
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||
Err(e) => return Err(format!("Read chunks dir: {e}")),
|
||
};
|
||
|
||
for entry in entries.flatten() {
|
||
let file_path = entry.path();
|
||
if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||
continue;
|
||
}
|
||
|
||
let chunks: Vec<ChunkJson> = read_json(&file_path)?;
|
||
|
||
for chunk in chunks {
|
||
let text_lower = chunk.text.to_lowercase();
|
||
let matched = terms.iter().filter(|t| text_lower.contains(*t)).count();
|
||
if matched == 0 {
|
||
continue;
|
||
}
|
||
|
||
let score = matched as f64 / total_terms;
|
||
results.push(SearchResult {
|
||
chunk_id: chunk.id,
|
||
path: chunk.path,
|
||
source: chunk.source,
|
||
text: chunk.text,
|
||
score,
|
||
start_line: chunk.start_line,
|
||
end_line: chunk.end_line,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sort by score descending
|
||
results.sort_by(|a, b| {
|
||
b.score
|
||
.partial_cmp(&a.score)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
results.truncate(limit as usize);
|
||
|
||
Ok(results)
|
||
}
|
||
|
||
/// Get all chunks for a file path.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_get_chunks(path: String) -> Result<Vec<ChunkRecord>, String> {
|
||
let chunk_path = chunk_file_path(&path)?;
|
||
let chunks: Vec<ChunkJson> = read_json(&chunk_path)?;
|
||
let mut records: Vec<ChunkRecord> = chunks.into_iter().map(ChunkRecord::from).collect();
|
||
records.sort_by_key(|c| c.start_line);
|
||
Ok(records)
|
||
}
|
||
|
||
/// Get all embeddings for vector search (returns chunk IDs + embeddings).
|
||
#[tauri::command]
|
||
pub async fn ai_memory_get_all_embeddings() -> Result<Vec<(String, Vec<u8>)>, String> {
|
||
let chunks_dir = get_chunks_dir()?;
|
||
let mut results: Vec<(String, Vec<u8>)> = Vec::new();
|
||
|
||
let entries = match std::fs::read_dir(&chunks_dir) {
|
||
Ok(e) => e,
|
||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||
Err(e) => return Err(format!("Read chunks dir: {e}")),
|
||
};
|
||
|
||
for entry in entries.flatten() {
|
||
let file_path = entry.path();
|
||
if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||
continue;
|
||
}
|
||
|
||
let chunks: Vec<ChunkJson> = read_json(&file_path)?;
|
||
for chunk in chunks {
|
||
if let Some(b64) = chunk.embedding_b64 {
|
||
if let Ok(bytes) = BASE64.decode(&b64) {
|
||
results.push((chunk.id, bytes));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(results)
|
||
}
|
||
|
||
/// Cache an embedding result.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_cache_embedding(entry: EmbeddingCacheEntry) -> Result<bool, String> {
|
||
let cache_path = embedding_cache_path()?;
|
||
let mut cache: Vec<EmbeddingCacheJson> = read_json(&cache_path)?;
|
||
|
||
// Remove existing entry with same key
|
||
cache.retain(|e| {
|
||
!(e.provider == entry.provider && e.model == entry.model && e.hash == entry.hash)
|
||
});
|
||
|
||
cache.push(EmbeddingCacheJson::from(entry));
|
||
write_json(&cache_path, &cache)?;
|
||
Ok(true)
|
||
}
|
||
|
||
/// Look up a cached embedding.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_get_cached_embedding(
|
||
provider: String,
|
||
model: String,
|
||
hash: String,
|
||
) -> Result<Option<Vec<u8>>, String> {
|
||
let cache_path = embedding_cache_path()?;
|
||
let cache: Vec<EmbeddingCacheJson> = read_json(&cache_path)?;
|
||
|
||
let entry = cache
|
||
.into_iter()
|
||
.find(|e| e.provider == provider && e.model == model && e.hash == hash);
|
||
|
||
Ok(entry.and_then(|e| BASE64.decode(&e.embedding_b64).ok()))
|
||
}
|
||
|
||
/// Set a metadata value.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_set_meta(key: String, value: String) -> Result<bool, String> {
|
||
let path = meta_json_path()?;
|
||
let mut meta: HashMap<String, String> = read_json(&path)?;
|
||
meta.insert(key, value);
|
||
write_json(&path, &meta)?;
|
||
Ok(true)
|
||
}
|
||
|
||
/// Get a metadata value.
|
||
#[tauri::command]
|
||
pub async fn ai_memory_get_meta(key: String) -> Result<Option<String>, String> {
|
||
let path = meta_json_path()?;
|
||
let meta: HashMap<String, String> = read_json(&path)?;
|
||
Ok(meta.get(&key).cloned())
|
||
}
|