Files
zclaw_openfang/desktop/src-tauri/src/viking_commands.rs
2026-03-17 23:26:16 +08:00

371 lines
10 KiB
Rust

//! OpenViking CLI Sidecar Integration
//!
//! Wraps the OpenViking Rust CLI (`ov`) as a Tauri sidecar for local memory operations.
//! This eliminates the need for a Python server dependency.
//!
//! Reference: https://github.com/volcengine/OpenViking
use serde::{Deserialize, Serialize};
use std::process::Command;
// === Types ===
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VikingStatus {
pub available: bool,
pub version: Option<String>,
pub data_dir: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VikingResource {
pub uri: String,
pub name: String,
#[serde(rename = "type")]
pub resource_type: String,
pub size: Option<u64>,
pub modified_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VikingFindResult {
pub uri: String,
pub score: f64,
pub content: String,
pub level: String,
pub overview: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VikingGrepResult {
pub uri: String,
pub line: u32,
pub content: String,
pub match_start: u32,
pub match_end: u32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VikingAddResult {
pub uri: String,
pub status: String,
}
// === CLI Path Resolution ===
fn get_viking_cli_path() -> Result<String, String> {
// Try environment variable first
if let Ok(path) = std::env::var("ZCLAW_VIKING_BIN") {
if std::path::Path::new(&path).exists() {
return Ok(path);
}
}
// Try bundled sidecar location
let binary_name = if cfg!(target_os = "windows") {
"ov-x86_64-pc-windows-msvc.exe"
} else if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
"ov-aarch64-apple-darwin"
} else {
"ov-x86_64-apple-darwin"
}
} else {
"ov-x86_64-unknown-linux-gnu"
};
// Check common locations
let locations = vec![
format!("./binaries/{}", binary_name),
format!("./resources/viking/{}", binary_name),
format!("./{}", binary_name),
];
for loc in locations {
if std::path::Path::new(&loc).exists() {
return Ok(loc);
}
}
// Fallback to system PATH
Ok("ov".to_string())
}
fn run_viking_cli(args: &[&str]) -> Result<String, String> {
let cli_path = get_viking_cli_path()?;
let output = Command::new(&cli_path)
.args(args)
.output()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
format!(
"OpenViking CLI not found. Please install 'ov' or set ZCLAW_VIKING_BIN. Tried: {}",
cli_path
)
} else {
format!("Failed to run OpenViking CLI: {}", e)
}
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stderr.is_empty() {
Err(stderr)
} else if !stdout.is_empty() {
Err(stdout)
} else {
Err(format!("OpenViking CLI failed with status: {}", output.status))
}
}
}
/// Helper function to run Viking CLI and parse JSON output
/// Reserved for future JSON-based commands
#[allow(dead_code)]
fn run_viking_cli_json<T: for<'de> Deserialize<'de>>(args: &[&str]) -> Result<T, String> {
let output = run_viking_cli(args)?;
// Handle empty output
if output.is_empty() {
return Err("OpenViking CLI returned empty output".to_string());
}
// Try to parse as JSON
serde_json::from_str(&output)
.map_err(|e| format!("Failed to parse OpenViking output as JSON: {}\nOutput: {}", e, output))
}
// === Tauri Commands ===
/// Check if OpenViking CLI is available
#[tauri::command]
pub fn viking_status() -> Result<VikingStatus, String> {
let result = run_viking_cli(&["--version"]);
match result {
Ok(version_output) => {
// Parse version from output like "ov 0.1.0"
let version = version_output
.lines()
.next()
.map(|s| s.trim().to_string());
Ok(VikingStatus {
available: true,
version,
data_dir: None, // TODO: Get from CLI
error: None,
})
}
Err(e) => Ok(VikingStatus {
available: false,
version: None,
data_dir: None,
error: Some(e),
}),
}
}
/// Add a resource to OpenViking
#[tauri::command]
pub fn viking_add(uri: String, content: String) -> Result<VikingAddResult, String> {
// Create a temporary file for the content
let temp_dir = std::env::temp_dir();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let temp_file = temp_dir.join(format!("viking_add_{}.txt", timestamp));
std::fs::write(&temp_file, &content)
.map_err(|e| format!("Failed to write temp file: {}", e))?;
let temp_path = temp_file.to_string_lossy();
let result = run_viking_cli(&["add", &uri, "--file", &temp_path]);
// Clean up temp file
let _ = std::fs::remove_file(&temp_file);
match result {
Ok(_) => Ok(VikingAddResult {
uri,
status: "added".to_string(),
}),
Err(e) => Err(e),
}
}
/// Add a resource with inline content (for small content)
#[tauri::command]
pub fn viking_add_inline(uri: String, content: String) -> Result<VikingAddResult, String> {
// Use stdin for content
let cli_path = get_viking_cli_path()?;
let output = Command::new(&cli_path)
.args(["add", &uri])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn OpenViking CLI: {}", e))?;
// Write content to stdin
if let Some(mut stdin) = output.stdin.as_ref() {
use std::io::Write;
stdin.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
}
let result = output.wait_with_output()
.map_err(|e| format!("Failed to read output: {}", e))?;
if result.status.success() {
Ok(VikingAddResult {
uri,
status: "added".to_string(),
})
} else {
let stderr = String::from_utf8_lossy(&result.stderr).trim().to_string();
Err(if !stderr.is_empty() { stderr } else { "Failed to add resource".to_string() })
}
}
/// Find resources by semantic search
#[tauri::command]
pub fn viking_find(
query: String,
scope: Option<String>,
limit: Option<usize>,
) -> Result<Vec<VikingFindResult>, String> {
let mut args = vec!["find", "--json", &query];
let scope_arg;
if let Some(ref s) = scope {
scope_arg = format!("--scope={}", s);
args.push(&scope_arg);
}
let limit_arg;
if let Some(l) = limit {
limit_arg = format!("--limit={}", l);
args.push(&limit_arg);
}
// CLI returns JSON array directly
let output = run_viking_cli(&args)?;
// Handle empty or null results
if output.is_empty() || output == "null" || output == "[]" {
return Ok(Vec::new());
}
serde_json::from_str(&output)
.map_err(|e| format!("Failed to parse find results: {}\nOutput: {}", e, output))
}
/// Grep resources by pattern
#[tauri::command]
pub fn viking_grep(
pattern: String,
uri: Option<String>,
case_sensitive: Option<bool>,
limit: Option<usize>,
) -> Result<Vec<VikingGrepResult>, String> {
let mut args = vec!["grep", "--json", &pattern];
let uri_arg;
if let Some(ref u) = uri {
uri_arg = format!("--uri={}", u);
args.push(&uri_arg);
}
if case_sensitive.unwrap_or(false) {
args.push("--case-sensitive");
}
let limit_arg;
if let Some(l) = limit {
limit_arg = format!("--limit={}", l);
args.push(&limit_arg);
}
let output = run_viking_cli(&args)?;
if output.is_empty() || output == "null" || output == "[]" {
return Ok(Vec::new());
}
serde_json::from_str(&output)
.map_err(|e| format!("Failed to parse grep results: {}\nOutput: {}", e, output))
}
/// List resources at a path
#[tauri::command]
pub fn viking_ls(path: String) -> Result<Vec<VikingResource>, String> {
let output = run_viking_cli(&["ls", "--json", &path])?;
if output.is_empty() || output == "null" || output == "[]" {
return Ok(Vec::new());
}
serde_json::from_str(&output)
.map_err(|e| format!("Failed to parse ls results: {}\nOutput: {}", e, output))
}
/// Read resource content
#[tauri::command]
pub fn viking_read(uri: String, level: Option<String>) -> Result<String, String> {
let level_val = level.unwrap_or_else(|| "L1".to_string());
let level_arg = format!("--level={}", level_val);
run_viking_cli(&["read", &uri, &level_arg])
}
/// Remove a resource
#[tauri::command]
pub fn viking_remove(uri: String) -> Result<(), String> {
run_viking_cli(&["remove", &uri])?;
Ok(())
}
/// Get resource tree
#[tauri::command]
pub fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_json::Value, String> {
let depth_val = depth.unwrap_or(2);
let depth_arg = format!("--depth={}", depth_val);
let output = run_viking_cli(&["tree", "--json", &path, &depth_arg])?;
if output.is_empty() || output == "null" {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&output)
.map_err(|e| format!("Failed to parse tree result: {}\nOutput: {}", e, output))
}
// === Tests ===
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_status_unavailable_without_cli() {
// This test will fail if ov is installed, which is fine
let result = viking_status();
assert!(result.is_ok());
}
}