feat(viking): add local server management for privacy-first deployment
- Add viking_server.rs (Rust) for managing local OpenViking server process - Add viking-server-manager.ts (TypeScript) for server control from UI - Update VikingAdapter to support 'local' mode with auto-start capability - Update documentation for local deployment mode Key features: - Auto-start local server when needed - All data stays in ~/.openviking/ (privacy-first) - Server listens only on 127.0.0.1 - Graceful fallback to remote/localStorage modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,4 +22,9 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11", features = ["json", "blocking"] }
|
||||
chrono = "0.4"
|
||||
regex = "1"
|
||||
dirs = "5"
|
||||
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
// - Port: 4200 (was 18789)
|
||||
// - Binary: openfang (was openclaw)
|
||||
// - Config: ~/.openfang/openfang.toml (was ~/.openclaw/openclaw.json)
|
||||
|
||||
// Viking CLI sidecar module for local memory operations
|
||||
mod viking_commands;
|
||||
mod viking_server;
|
||||
|
||||
// Memory extraction and context building modules (supplement CLI)
|
||||
mod memory;
|
||||
mod llm;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
@@ -1006,7 +1015,27 @@ pub fn run() {
|
||||
gateway_local_auth,
|
||||
gateway_prepare_for_tauri,
|
||||
gateway_approve_device_pairing,
|
||||
gateway_doctor
|
||||
gateway_doctor,
|
||||
// OpenViking CLI sidecar commands
|
||||
viking_commands::viking_status,
|
||||
viking_commands::viking_add,
|
||||
viking_commands::viking_add_inline,
|
||||
viking_commands::viking_find,
|
||||
viking_commands::viking_grep,
|
||||
viking_commands::viking_ls,
|
||||
viking_commands::viking_read,
|
||||
viking_commands::viking_remove,
|
||||
viking_commands::viking_tree,
|
||||
// Viking server management (local deployment)
|
||||
viking_server::viking_server_status,
|
||||
viking_server::viking_server_start,
|
||||
viking_server::viking_server_stop,
|
||||
viking_server::viking_server_restart,
|
||||
// Memory extraction commands (supplement CLI)
|
||||
memory::extractor::extract_session_memories,
|
||||
memory::context_builder::estimate_content_tokens,
|
||||
// LLM commands (for extraction)
|
||||
llm::llm_complete
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
295
desktop/src-tauri/src/viking_server.rs
Normal file
295
desktop/src-tauri/src/viking_server.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
//! OpenViking Local Server Management
|
||||
//!
|
||||
//! Manages a local OpenViking server instance for privacy-first deployment.
|
||||
//! All data is stored locally in ~/.openviking/ - nothing is uploaded to remote servers.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! ┌─────────────────────────────────────────────────────────────────┐
|
||||
//! │ ZCLAW Desktop (Tauri) │
|
||||
//! │ │
|
||||
//! │ ┌─────────────────┐ HTTP ┌─────────────────────────┐ │
|
||||
//! │ │ viking_commands │ ◄────────────►│ openviking-server │ │
|
||||
//! │ │ (Tauri cmds) │ localhost │ (Python, managed here) │ │
|
||||
//! │ └─────────────────┘ └───────────┬─────────────┘ │
|
||||
//! │ │ │
|
||||
//! │ ┌─────────▼─────────────┐ │
|
||||
//! │ │ SQLite + Vector Store │ │
|
||||
//! │ │ ~/.openviking/ │ │
|
||||
//! │ │ (LOCAL DATA ONLY) │ │
|
||||
//! │ └───────────────────────┘ │
|
||||
//! └─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
// === Types ===
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerStatus {
|
||||
pub running: bool,
|
||||
pub port: u16,
|
||||
pub pid: Option<u32>,
|
||||
pub data_dir: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerConfig {
|
||||
pub port: u16,
|
||||
pub data_dir: String,
|
||||
pub config_file: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
let home = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
|
||||
Self {
|
||||
port: 1933,
|
||||
data_dir: format!("{}/.openviking/workspace", home),
|
||||
config_file: Some(format!("{}/.openviking/ov.conf", home)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Server Process Management ===
|
||||
|
||||
static SERVER_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
|
||||
|
||||
/// Check if OpenViking server is running
|
||||
fn is_server_running(port: u16) -> bool {
|
||||
// Try to connect to the server
|
||||
let url = format!("http://127.0.0.1:{}/api/v1/status", port);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.ok();
|
||||
|
||||
if let Some(client) = client {
|
||||
if let Ok(resp) = client.get(&url).send() {
|
||||
return resp.status().is_success();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find openviking-server executable
|
||||
fn find_server_binary() -> Result<String, String> {
|
||||
// Check environment variable first
|
||||
if let Ok(path) = std::env::var("ZCLAW_VIKING_SERVER_BIN") {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check common locations
|
||||
let candidates = vec![
|
||||
"openviking-server".to_string(),
|
||||
"python -m openviking.server".to_string(),
|
||||
];
|
||||
|
||||
// Try to find in PATH
|
||||
for cmd in &candidates {
|
||||
if Command::new("which")
|
||||
.arg(cmd.split_whitespace().next().unwrap_or(""))
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(cmd.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Python virtual environment
|
||||
let home = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let venv_candidates = vec![
|
||||
format!("{}/.openviking/venv/bin/openviking-server", home),
|
||||
format!("{}/.local/bin/openviking-server", home),
|
||||
];
|
||||
|
||||
for path in venv_candidates {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: assume it's in PATH via pip install
|
||||
Ok("openviking-server".to_string())
|
||||
}
|
||||
|
||||
// === Tauri Commands ===
|
||||
|
||||
/// Get server status
|
||||
#[tauri::command]
|
||||
pub fn viking_server_status() -> Result<ServerStatus, String> {
|
||||
let config = ServerConfig::default();
|
||||
|
||||
let running = is_server_running(config.port);
|
||||
|
||||
let pid = if running {
|
||||
SERVER_PROCESS
|
||||
.lock()
|
||||
.map(|guard| guard.as_ref().map(|c| c.id()))
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get version if running
|
||||
let version = if running {
|
||||
let url = format!("http://127.0.0.1:{}/api/v1/version", config.port);
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.ok()
|
||||
.and_then(|client| client.get(&url).send().ok())
|
||||
.and_then(|resp| resp.text().ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ServerStatus {
|
||||
running,
|
||||
port: config.port,
|
||||
pid,
|
||||
data_dir: Some(config.data_dir),
|
||||
version,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start local OpenViking server
|
||||
#[tauri::command]
|
||||
pub fn viking_server_start(config: Option<ServerConfig>) -> Result<ServerStatus, String> {
|
||||
let config = config.unwrap_or_default();
|
||||
|
||||
// Check if already running
|
||||
if is_server_running(config.port) {
|
||||
return Ok(ServerStatus {
|
||||
running: true,
|
||||
port: config.port,
|
||||
pid: None,
|
||||
data_dir: Some(config.data_dir),
|
||||
version: None,
|
||||
error: Some("Server already running".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Find server binary
|
||||
let server_bin = find_server_binary()?;
|
||||
|
||||
// Ensure data directory exists
|
||||
std::fs::create_dir_all(&config.data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
|
||||
// Set environment variables
|
||||
if let Some(ref config_file) = config.config_file {
|
||||
std::env::set_var("OPENVIKING_CONFIG_FILE", config_file);
|
||||
}
|
||||
|
||||
// Start server process
|
||||
let child = if server_bin.contains("python") {
|
||||
// Use Python module
|
||||
let parts: Vec<&str> = server_bin.split_whitespace().collect();
|
||||
Command::new(parts[0])
|
||||
.args(&parts[1..])
|
||||
.arg("--host")
|
||||
.arg("127.0.0.1")
|
||||
.arg("--port")
|
||||
.arg(config.port.to_string())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server: {}", e))?
|
||||
} else {
|
||||
// Direct binary
|
||||
Command::new(&server_bin)
|
||||
.arg("--host")
|
||||
.arg("127.0.0.1")
|
||||
.arg("--port")
|
||||
.arg(config.port.to_string())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start server: {}", e))?
|
||||
};
|
||||
|
||||
let pid = child.id();
|
||||
|
||||
// Store process handle
|
||||
if let Ok(mut guard) = SERVER_PROCESS.lock() {
|
||||
*guard = Some(child);
|
||||
}
|
||||
|
||||
// Wait for server to be ready
|
||||
let mut ready = false;
|
||||
for _ in 0..30 {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
if is_server_running(config.port) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !ready {
|
||||
return Err("Server failed to start within 15 seconds".to_string());
|
||||
}
|
||||
|
||||
Ok(ServerStatus {
|
||||
running: true,
|
||||
port: config.port,
|
||||
pid: Some(pid),
|
||||
data_dir: Some(config.data_dir),
|
||||
version: None,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop local OpenViking server
|
||||
#[tauri::command]
|
||||
pub fn viking_server_stop() -> Result<(), String> {
|
||||
if let Ok(mut guard) = SERVER_PROCESS.lock() {
|
||||
if let Some(mut child) = guard.take() {
|
||||
child.kill().map_err(|e| format!("Failed to kill server: {}", e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restart local OpenViking server
|
||||
#[tauri::command]
|
||||
pub fn viking_server_restart(config: Option<ServerConfig>) -> Result<ServerStatus, String> {
|
||||
viking_server_stop()?;
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
viking_server_start(config)
|
||||
}
|
||||
|
||||
// === Tests ===
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_server_config_default() {
|
||||
let config = ServerConfig::default();
|
||||
assert_eq!(config.port, 1933);
|
||||
assert!(config.data_dir.contains(".openviking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_server_running_not_running() {
|
||||
// Should return false when no server is running on port 1933
|
||||
let result = is_server_running(1933);
|
||||
// Just check it doesn't panic
|
||||
assert!(result || !result);
|
||||
}
|
||||
}
|
||||
734
desktop/src/lib/viking-adapter.ts
Normal file
734
desktop/src/lib/viking-adapter.ts
Normal file
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* Viking Adapter - ZCLAW ↔ OpenViking Integration Layer
|
||||
*
|
||||
* Maps ZCLAW agent concepts (memories, identity, skills) to OpenViking's
|
||||
* viking:// URI namespace. Provides high-level operations for:
|
||||
* - User memory management (preferences, facts, history)
|
||||
* - Agent memory management (lessons, patterns, tool tips)
|
||||
* - L0/L1/L2 layered context building (token-efficient)
|
||||
* - Session memory extraction (auto-learning)
|
||||
* - Identity file synchronization
|
||||
* - Retrieval trace capture (debuggability)
|
||||
*
|
||||
* Supports three modes:
|
||||
* - local: Manages a local OpenViking server (privacy-first, data stays local)
|
||||
* - sidecar: Uses OpenViking CLI via Tauri commands (direct CLI integration)
|
||||
* - remote: Uses OpenViking HTTP Server (connects to external server)
|
||||
*
|
||||
* For privacy-conscious users, use 'local' mode which ensures all data
|
||||
* stays on the local machine in ~/.openviking/
|
||||
*/
|
||||
|
||||
import {
|
||||
VikingHttpClient,
|
||||
type FindResult,
|
||||
type RetrievalTrace,
|
||||
type ExtractedMemory,
|
||||
type SessionExtractionResult,
|
||||
type ContextLevel,
|
||||
type VikingEntry,
|
||||
type VikingTreeNode,
|
||||
} from './viking-client';
|
||||
import {
|
||||
getVikingServerManager,
|
||||
type VikingServerStatus,
|
||||
} from './viking-server-manager';
|
||||
|
||||
// Tauri invoke import (safe to import even if not in Tauri context)
|
||||
let invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
||||
|
||||
try {
|
||||
// Dynamic import for Tauri API
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
invoke = require('@tauri-apps/api/core').invoke;
|
||||
} catch {
|
||||
// Not in Tauri context, invoke will be null
|
||||
console.log('[VikingAdapter] Not in Tauri context, sidecar mode unavailable');
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface MemoryResult {
|
||||
uri: string;
|
||||
content: string;
|
||||
score: number;
|
||||
level: ContextLevel;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface EnhancedContext {
|
||||
systemPromptAddition: string;
|
||||
memories: MemoryResult[];
|
||||
totalTokens: number;
|
||||
tokensByLevel: { L0: number; L1: number; L2: number };
|
||||
trace?: RetrievalTrace;
|
||||
}
|
||||
|
||||
export interface MemorySaveResult {
|
||||
uri: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
saved: number;
|
||||
userMemories: number;
|
||||
agentMemories: number;
|
||||
details: ExtractedMemory[];
|
||||
}
|
||||
|
||||
export interface IdentityFile {
|
||||
name: string;
|
||||
content: string;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
export interface IdentityChangeProposal {
|
||||
file: string;
|
||||
currentContent: string;
|
||||
suggestedContent: string;
|
||||
reason: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface VikingAdapterConfig {
|
||||
serverUrl: string;
|
||||
defaultAgentId: string;
|
||||
maxContextTokens: number;
|
||||
l0Limit: number;
|
||||
l1Limit: number;
|
||||
minRelevanceScore: number;
|
||||
enableTrace: boolean;
|
||||
mode?: VikingMode;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: VikingAdapterConfig = {
|
||||
serverUrl: 'http://localhost:1933',
|
||||
defaultAgentId: 'zclaw-main',
|
||||
maxContextTokens: 8000,
|
||||
l0Limit: 30,
|
||||
l1Limit: 15,
|
||||
minRelevanceScore: 0.5,
|
||||
enableTrace: true,
|
||||
};
|
||||
|
||||
// === URI Helpers ===
|
||||
|
||||
const VIKING_NS = {
|
||||
userMemories: 'viking://user/memories',
|
||||
userPreferences: 'viking://user/memories/preferences',
|
||||
userFacts: 'viking://user/memories/facts',
|
||||
userHistory: 'viking://user/memories/history',
|
||||
agentBase: (agentId: string) => `viking://agent/${agentId}`,
|
||||
agentIdentity: (agentId: string) => `viking://agent/${agentId}/identity`,
|
||||
agentMemories: (agentId: string) => `viking://agent/${agentId}/memories`,
|
||||
agentLessons: (agentId: string) => `viking://agent/${agentId}/memories/lessons_learned`,
|
||||
agentPatterns: (agentId: string) => `viking://agent/${agentId}/memories/task_patterns`,
|
||||
agentToolTips: (agentId: string) => `viking://agent/${agentId}/memories/tool_tips`,
|
||||
agentSkills: (agentId: string) => `viking://agent/${agentId}/skills`,
|
||||
sharedKnowledge: 'viking://agent/shared/common_knowledge',
|
||||
resources: 'viking://resources',
|
||||
} as const;
|
||||
|
||||
// === Rough Token Estimator ===
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
// ~1.5 tokens per CJK character, ~0.75 tokens per English word
|
||||
const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const otherChars = text.length - cjkChars;
|
||||
return Math.ceil(cjkChars * 1.5 + otherChars * 0.4);
|
||||
}
|
||||
|
||||
// === Mode Type ===
|
||||
|
||||
export type VikingMode = 'local' | 'sidecar' | 'remote' | 'auto';
|
||||
|
||||
// === Adapter Implementation ===
|
||||
|
||||
export class VikingAdapter {
|
||||
private client: VikingHttpClient;
|
||||
private config: VikingAdapterConfig;
|
||||
private lastTrace: RetrievalTrace | null = null;
|
||||
private mode: VikingMode;
|
||||
private resolvedMode: 'local' | 'sidecar' | 'remote' | null = null;
|
||||
private serverManager = getVikingServerManager();
|
||||
|
||||
constructor(config?: Partial<VikingAdapterConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.client = new VikingHttpClient(this.config.serverUrl);
|
||||
this.mode = config?.mode ?? 'auto';
|
||||
}
|
||||
|
||||
// === Mode Detection ===
|
||||
|
||||
private async detectMode(): Promise<'local' | 'sidecar' | 'remote'> {
|
||||
if (this.resolvedMode) {
|
||||
return this.resolvedMode;
|
||||
}
|
||||
|
||||
if (this.mode === 'local') {
|
||||
this.resolvedMode = 'local';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
if (this.mode === 'sidecar') {
|
||||
this.resolvedMode = 'sidecar';
|
||||
return 'sidecar';
|
||||
}
|
||||
|
||||
if (this.mode === 'remote') {
|
||||
this.resolvedMode = 'remote';
|
||||
return 'remote';
|
||||
}
|
||||
|
||||
// Auto mode: try local server first (privacy-first), then sidecar, then remote
|
||||
// 1. Check if local server is already running or can be started
|
||||
if (invoke) {
|
||||
try {
|
||||
const status = await this.serverManager.getStatus();
|
||||
if (status.running) {
|
||||
console.log('[VikingAdapter] Using local mode (OpenViking local server already running)');
|
||||
this.resolvedMode = 'local';
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// Try to start local server
|
||||
const started = await this.serverManager.ensureRunning();
|
||||
if (started) {
|
||||
console.log('[VikingAdapter] Using local mode (OpenViking local server started)');
|
||||
this.resolvedMode = 'local';
|
||||
return 'local';
|
||||
}
|
||||
} catch {
|
||||
console.log('[VikingAdapter] Local server not available, trying sidecar');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try sidecar mode
|
||||
if (invoke) {
|
||||
try {
|
||||
const status = await invoke('viking_status') as { available: boolean };
|
||||
if (status.available) {
|
||||
console.log('[VikingAdapter] Using sidecar mode (OpenViking CLI)');
|
||||
this.resolvedMode = 'sidecar';
|
||||
return 'sidecar';
|
||||
}
|
||||
} catch {
|
||||
console.log('[VikingAdapter] Sidecar mode not available, trying remote');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try remote mode
|
||||
if (await this.client.isAvailable()) {
|
||||
console.log('[VikingAdapter] Using remote mode (OpenViking Server)');
|
||||
this.resolvedMode = 'remote';
|
||||
return 'remote';
|
||||
}
|
||||
|
||||
console.warn('[VikingAdapter] No Viking backend available');
|
||||
return 'remote'; // Default fallback
|
||||
}
|
||||
|
||||
getMode(): 'local' | 'sidecar' | 'remote' | null {
|
||||
return this.resolvedMode;
|
||||
}
|
||||
|
||||
// === Connection ===
|
||||
|
||||
async isConnected(): Promise<boolean> {
|
||||
const mode = await this.detectMode();
|
||||
|
||||
if (mode === 'local') {
|
||||
const status = await this.serverManager.getStatus();
|
||||
return status.running;
|
||||
}
|
||||
|
||||
if (mode === 'sidecar') {
|
||||
try {
|
||||
if (!invoke) return false;
|
||||
const status = await invoke('viking_status') as { available: boolean };
|
||||
return status.available;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return this.client.isAvailable();
|
||||
}
|
||||
|
||||
// === Server Management (for local mode) ===
|
||||
|
||||
/**
|
||||
* Get the local server status (for local mode)
|
||||
*/
|
||||
async getLocalServerStatus(): Promise<VikingServerStatus> {
|
||||
return this.serverManager.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the local server (for local mode)
|
||||
*/
|
||||
async startLocalServer(): Promise<VikingServerStatus> {
|
||||
return this.serverManager.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the local server (for local mode)
|
||||
*/
|
||||
async stopLocalServer(): Promise<void> {
|
||||
return this.serverManager.stop();
|
||||
}
|
||||
|
||||
getLastTrace(): RetrievalTrace | null {
|
||||
return this.lastTrace;
|
||||
}
|
||||
|
||||
// === User Memory Operations ===
|
||||
|
||||
async saveUserPreference(
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<MemorySaveResult> {
|
||||
const uri = `${VIKING_NS.userPreferences}/${sanitizeKey(key)}`;
|
||||
return this.client.addResource(uri, value, {
|
||||
metadata: { type: 'preference', key, updated_at: new Date().toISOString() },
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
async saveUserFact(
|
||||
category: string,
|
||||
content: string,
|
||||
tags?: string[]
|
||||
): Promise<MemorySaveResult> {
|
||||
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const uri = `${VIKING_NS.userFacts}/${sanitizeKey(category)}/${id}`;
|
||||
return this.client.addResource(uri, content, {
|
||||
metadata: {
|
||||
type: 'fact',
|
||||
category,
|
||||
tags: (tags || []).join(','),
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
async searchUserMemories(
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<MemoryResult[]> {
|
||||
const results = await this.client.find(query, {
|
||||
scope: VIKING_NS.userMemories,
|
||||
limit,
|
||||
level: 'L1',
|
||||
minScore: this.config.minRelevanceScore,
|
||||
});
|
||||
return results.map(toMemoryResult);
|
||||
}
|
||||
|
||||
async getUserPreferences(): Promise<VikingEntry[]> {
|
||||
try {
|
||||
return await this.client.ls(VIKING_NS.userPreferences);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// === Agent Memory Operations ===
|
||||
|
||||
async saveAgentLesson(
|
||||
agentId: string,
|
||||
lesson: string,
|
||||
tags?: string[]
|
||||
): Promise<MemorySaveResult> {
|
||||
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const uri = `${VIKING_NS.agentLessons(agentId)}/${id}`;
|
||||
return this.client.addResource(uri, lesson, {
|
||||
metadata: {
|
||||
type: 'lesson',
|
||||
tags: (tags || []).join(','),
|
||||
agent_id: agentId,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
async saveAgentPattern(
|
||||
agentId: string,
|
||||
pattern: string,
|
||||
tags?: string[]
|
||||
): Promise<MemorySaveResult> {
|
||||
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const uri = `${VIKING_NS.agentPatterns(agentId)}/${id}`;
|
||||
return this.client.addResource(uri, pattern, {
|
||||
metadata: {
|
||||
type: 'pattern',
|
||||
tags: (tags || []).join(','),
|
||||
agent_id: agentId,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
async saveAgentToolTip(
|
||||
agentId: string,
|
||||
tip: string,
|
||||
toolName: string
|
||||
): Promise<MemorySaveResult> {
|
||||
const uri = `${VIKING_NS.agentToolTips(agentId)}/${sanitizeKey(toolName)}`;
|
||||
return this.client.addResource(uri, tip, {
|
||||
metadata: {
|
||||
type: 'tool_tip',
|
||||
tool: toolName,
|
||||
agent_id: agentId,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
async searchAgentMemories(
|
||||
agentId: string,
|
||||
query: string,
|
||||
limit: number = 10
|
||||
): Promise<MemoryResult[]> {
|
||||
const results = await this.client.find(query, {
|
||||
scope: VIKING_NS.agentMemories(agentId),
|
||||
limit,
|
||||
level: 'L1',
|
||||
minScore: this.config.minRelevanceScore,
|
||||
});
|
||||
return results.map(toMemoryResult);
|
||||
}
|
||||
|
||||
// === Identity File Management ===
|
||||
|
||||
async syncIdentityToViking(
|
||||
agentId: string,
|
||||
fileName: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
|
||||
await this.client.addResource(uri, content, {
|
||||
metadata: {
|
||||
type: 'identity',
|
||||
file: fileName,
|
||||
agent_id: agentId,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getIdentityFromViking(
|
||||
agentId: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
|
||||
return this.client.readContent(uri, 'L2');
|
||||
}
|
||||
|
||||
async proposeIdentityChange(
|
||||
agentId: string,
|
||||
proposal: IdentityChangeProposal
|
||||
): Promise<MemorySaveResult> {
|
||||
const id = `${Date.now()}`;
|
||||
const uri = `${VIKING_NS.agentIdentity(agentId)}/changelog/${id}`;
|
||||
const content = [
|
||||
`# Identity Change Proposal`,
|
||||
`**File**: ${proposal.file}`,
|
||||
`**Reason**: ${proposal.reason}`,
|
||||
`**Timestamp**: ${proposal.timestamp}`,
|
||||
'',
|
||||
'## Current Content',
|
||||
'```',
|
||||
proposal.currentContent,
|
||||
'```',
|
||||
'',
|
||||
'## Suggested Content',
|
||||
'```',
|
||||
proposal.suggestedContent,
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
return this.client.addResource(uri, content, {
|
||||
metadata: {
|
||||
type: 'identity_change_proposal',
|
||||
file: proposal.file,
|
||||
status: 'pending',
|
||||
agent_id: agentId,
|
||||
},
|
||||
wait: true,
|
||||
});
|
||||
}
|
||||
|
||||
// === Core: Context Building (L0/L1/L2 layered loading) ===
|
||||
|
||||
async buildEnhancedContext(
|
||||
userMessage: string,
|
||||
agentId: string,
|
||||
options?: { maxTokens?: number; includeTrace?: boolean }
|
||||
): Promise<EnhancedContext> {
|
||||
const maxTokens = options?.maxTokens ?? this.config.maxContextTokens;
|
||||
const includeTrace = options?.includeTrace ?? this.config.enableTrace;
|
||||
|
||||
const tokensByLevel = { L0: 0, L1: 0, L2: 0 };
|
||||
|
||||
// Step 1: L0 fast scan across user + agent memories
|
||||
const [userL0, agentL0] = await Promise.all([
|
||||
this.client.find(userMessage, {
|
||||
scope: VIKING_NS.userMemories,
|
||||
level: 'L0',
|
||||
limit: this.config.l0Limit,
|
||||
}).catch(() => [] as FindResult[]),
|
||||
this.client.find(userMessage, {
|
||||
scope: VIKING_NS.agentMemories(agentId),
|
||||
level: 'L0',
|
||||
limit: this.config.l0Limit,
|
||||
}).catch(() => [] as FindResult[]),
|
||||
]);
|
||||
|
||||
const allL0 = [...userL0, ...agentL0];
|
||||
for (const r of allL0) {
|
||||
tokensByLevel.L0 += estimateTokens(r.content);
|
||||
}
|
||||
|
||||
// Step 2: Filter high-relevance items, load L1
|
||||
const relevant = allL0
|
||||
.filter(r => r.score >= this.config.minRelevanceScore)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, this.config.l1Limit);
|
||||
|
||||
const l1Results: MemoryResult[] = [];
|
||||
let tokenBudget = maxTokens;
|
||||
|
||||
for (const item of relevant) {
|
||||
try {
|
||||
const l1Content = await this.client.readContent(item.uri, 'L1');
|
||||
const tokens = estimateTokens(l1Content);
|
||||
|
||||
if (tokenBudget - tokens < 500) break; // Keep 500 token reserve
|
||||
|
||||
l1Results.push({
|
||||
uri: item.uri,
|
||||
content: l1Content,
|
||||
score: item.score,
|
||||
level: 'L1',
|
||||
category: extractCategory(item.uri),
|
||||
});
|
||||
|
||||
tokenBudget -= tokens;
|
||||
tokensByLevel.L1 += tokens;
|
||||
} catch {
|
||||
// Skip items that fail to load
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Build retrieval trace (if enabled)
|
||||
let trace: RetrievalTrace | undefined;
|
||||
if (includeTrace) {
|
||||
trace = {
|
||||
query: userMessage,
|
||||
steps: allL0.map(r => ({
|
||||
uri: r.uri,
|
||||
score: r.score,
|
||||
action: r.score >= this.config.minRelevanceScore ? 'entered' as const : 'skipped' as const,
|
||||
level: 'L0' as ContextLevel,
|
||||
})),
|
||||
totalTokensUsed: maxTokens - tokenBudget,
|
||||
tokensByLevel,
|
||||
duration: 0, // filled by caller if timing
|
||||
};
|
||||
this.lastTrace = trace;
|
||||
}
|
||||
|
||||
// Step 4: Format as system prompt addition
|
||||
const systemPromptAddition = formatMemoriesForPrompt(l1Results);
|
||||
|
||||
return {
|
||||
systemPromptAddition,
|
||||
memories: l1Results,
|
||||
totalTokens: maxTokens - tokenBudget,
|
||||
tokensByLevel,
|
||||
trace,
|
||||
};
|
||||
}
|
||||
|
||||
// === Session Memory Extraction ===
|
||||
|
||||
async extractAndSaveMemories(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
agentId: string,
|
||||
_conversationId?: string
|
||||
): Promise<ExtractionResult> {
|
||||
const sessionContent = messages
|
||||
.map(m => `[${m.role}]: ${m.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
let extraction: SessionExtractionResult;
|
||||
try {
|
||||
extraction = await this.client.extractMemories(sessionContent, agentId);
|
||||
} catch (err) {
|
||||
// If OpenViking extraction API is not available, use fallback
|
||||
console.warn('[VikingAdapter] Session extraction failed, using fallback:', err);
|
||||
return { saved: 0, userMemories: 0, agentMemories: 0, details: [] };
|
||||
}
|
||||
|
||||
let userCount = 0;
|
||||
let agentCount = 0;
|
||||
|
||||
for (const memory of extraction.memories) {
|
||||
try {
|
||||
if (memory.category === 'user_preference') {
|
||||
const key = memory.tags[0] || `pref_${Date.now()}`;
|
||||
await this.saveUserPreference(key, memory.content);
|
||||
userCount++;
|
||||
} else if (memory.category === 'user_fact') {
|
||||
const category = memory.tags[0] || 'general';
|
||||
await this.saveUserFact(category, memory.content, memory.tags);
|
||||
userCount++;
|
||||
} else if (memory.category === 'agent_lesson') {
|
||||
await this.saveAgentLesson(agentId, memory.content, memory.tags);
|
||||
agentCount++;
|
||||
} else if (memory.category === 'agent_pattern') {
|
||||
await this.saveAgentPattern(agentId, memory.content, memory.tags);
|
||||
agentCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[VikingAdapter] Failed to save memory:', memory.suggestedUri, err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saved: userCount + agentCount,
|
||||
userMemories: userCount,
|
||||
agentMemories: agentCount,
|
||||
details: extraction.memories,
|
||||
};
|
||||
}
|
||||
|
||||
// === Memory Browsing ===
|
||||
|
||||
async browseMemories(
|
||||
path: string = 'viking://'
|
||||
): Promise<VikingEntry[]> {
|
||||
try {
|
||||
return await this.client.ls(path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getMemoryTree(
|
||||
agentId: string,
|
||||
depth: number = 2
|
||||
): Promise<VikingTreeNode | null> {
|
||||
try {
|
||||
return await this.client.tree(VIKING_NS.agentBase(agentId), depth);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMemory(uri: string): Promise<void> {
|
||||
await this.client.removeResource(uri);
|
||||
}
|
||||
|
||||
// === Memory Statistics ===
|
||||
|
||||
async getMemoryStats(agentId: string): Promise<{
|
||||
totalEntries: number;
|
||||
userMemories: number;
|
||||
agentMemories: number;
|
||||
categories: Record<string, number>;
|
||||
}> {
|
||||
const [userEntries, agentEntries] = await Promise.all([
|
||||
this.client.ls(VIKING_NS.userMemories).catch(() => []),
|
||||
this.client.ls(VIKING_NS.agentMemories(agentId)).catch(() => []),
|
||||
]);
|
||||
|
||||
const categories: Record<string, number> = {};
|
||||
for (const entry of [...userEntries, ...agentEntries]) {
|
||||
const cat = extractCategory(entry.uri);
|
||||
categories[cat] = (categories[cat] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalEntries: userEntries.length + agentEntries.length,
|
||||
userMemories: userEntries.length,
|
||||
agentMemories: agentEntries.length,
|
||||
categories,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === Utility Functions ===
|
||||
|
||||
function sanitizeKey(key: string): string {
|
||||
return key
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff_-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '');
|
||||
}
|
||||
|
||||
function extractCategory(uri: string): string {
|
||||
const parts = uri.replace('viking://', '').split('/');
|
||||
// Return the 3rd segment as category (e.g., "preferences" from viking://user/memories/preferences/...)
|
||||
return parts[2] || parts[1] || 'unknown';
|
||||
}
|
||||
|
||||
function toMemoryResult(result: FindResult): MemoryResult {
|
||||
return {
|
||||
uri: result.uri,
|
||||
content: result.content,
|
||||
score: result.score,
|
||||
level: result.level,
|
||||
category: extractCategory(result.uri),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMemoriesForPrompt(memories: MemoryResult[]): string {
|
||||
if (memories.length === 0) return '';
|
||||
|
||||
const userMemories = memories.filter(m => m.uri.startsWith('viking://user/'));
|
||||
const agentMemories = memories.filter(m => m.uri.startsWith('viking://agent/'));
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (userMemories.length > 0) {
|
||||
sections.push('## 用户记忆');
|
||||
for (const m of userMemories) {
|
||||
sections.push(`- [${m.category}] ${m.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (agentMemories.length > 0) {
|
||||
sections.push('## Agent 经验');
|
||||
for (const m of agentMemories) {
|
||||
sections.push(`- [${m.category}] ${m.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
// === Singleton factory ===
|
||||
|
||||
let _instance: VikingAdapter | null = null;
|
||||
|
||||
export function getVikingAdapter(config?: Partial<VikingAdapterConfig>): VikingAdapter {
|
||||
if (!_instance || config) {
|
||||
_instance = new VikingAdapter(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetVikingAdapter(): void {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
export { VIKING_NS };
|
||||
231
desktop/src/lib/viking-server-manager.ts
Normal file
231
desktop/src/lib/viking-server-manager.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Viking Server Manager - Local OpenViking Server Management
|
||||
*
|
||||
* Manages a local OpenViking server instance for privacy-first deployment.
|
||||
* All data is stored locally in ~/.openviking/ - nothing is uploaded to remote servers.
|
||||
*
|
||||
* Usage:
|
||||
* const manager = getVikingServerManager();
|
||||
*
|
||||
* // Check server status
|
||||
* const status = await manager.getStatus();
|
||||
*
|
||||
* // Start server if not running
|
||||
* if (!status.running) {
|
||||
* await manager.start();
|
||||
* }
|
||||
*
|
||||
* // Server is now available at http://127.0.0.1:1933
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface VikingServerStatus {
|
||||
running: boolean;
|
||||
port: number;
|
||||
pid?: number;
|
||||
dataDir?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VikingServerConfig {
|
||||
port?: number;
|
||||
dataDir?: string;
|
||||
configFile?: string;
|
||||
}
|
||||
|
||||
// === Default Configuration ===
|
||||
|
||||
const DEFAULT_CONFIG: Required<VikingServerConfig> = {
|
||||
port: 1933,
|
||||
dataDir: '', // Will use default ~/.openviking/workspace
|
||||
configFile: '', // Will use default ~/.openviking/ov.conf
|
||||
};
|
||||
|
||||
// === Server Manager Class ===
|
||||
|
||||
export class VikingServerManager {
|
||||
private status: VikingServerStatus | null = null;
|
||||
private startPromise: Promise<VikingServerStatus> | null = null;
|
||||
|
||||
/**
|
||||
* Get current server status
|
||||
*/
|
||||
async getStatus(): Promise<VikingServerStatus> {
|
||||
try {
|
||||
this.status = await invoke<VikingServerStatus>('viking_server_status');
|
||||
return this.status;
|
||||
} catch (err) {
|
||||
console.error('[VikingServerManager] Failed to get status:', err);
|
||||
return {
|
||||
running: false,
|
||||
port: DEFAULT_CONFIG.port,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start local OpenViking server
|
||||
* If server is already running, returns current status
|
||||
*/
|
||||
async start(config?: VikingServerConfig): Promise<VikingServerStatus> {
|
||||
// Prevent concurrent start attempts
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
const currentStatus = await this.getStatus();
|
||||
if (currentStatus.running) {
|
||||
console.log('[VikingServerManager] Server already running on port', currentStatus.port);
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
this.startPromise = this.doStart(config);
|
||||
|
||||
try {
|
||||
const result = await this.startPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async doStart(config?: VikingServerConfig): Promise<VikingServerStatus> {
|
||||
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
console.log('[VikingServerManager] Starting local server on port', fullConfig.port);
|
||||
|
||||
try {
|
||||
const status = await invoke<VikingServerStatus>('viking_server_start', {
|
||||
config: {
|
||||
port: fullConfig.port,
|
||||
dataDir: fullConfig.dataDir || undefined,
|
||||
configFile: fullConfig.configFile || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
this.status = status;
|
||||
console.log('[VikingServerManager] Server started:', status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[VikingServerManager] Failed to start server:', errorMsg);
|
||||
|
||||
this.status = {
|
||||
running: false,
|
||||
port: fullConfig.port,
|
||||
error: errorMsg,
|
||||
};
|
||||
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop local OpenViking server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
console.log('[VikingServerManager] Stopping server');
|
||||
|
||||
try {
|
||||
await invoke('viking_server_stop');
|
||||
this.status = {
|
||||
running: false,
|
||||
port: DEFAULT_CONFIG.port,
|
||||
};
|
||||
console.log('[VikingServerManager] Server stopped');
|
||||
} catch (err) {
|
||||
console.error('[VikingServerManager] Failed to stop server:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart local OpenViking server
|
||||
*/
|
||||
async restart(config?: VikingServerConfig): Promise<VikingServerStatus> {
|
||||
console.log('[VikingServerManager] Restarting server');
|
||||
|
||||
try {
|
||||
const status = await invoke<VikingServerStatus>('viking_server_restart', {
|
||||
config: config ? {
|
||||
port: config.port,
|
||||
dataDir: config.dataDir,
|
||||
configFile: config.configFile,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
this.status = status;
|
||||
console.log('[VikingServerManager] Server restarted:', status);
|
||||
return status;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[VikingServerManager] Failed to restart server:', errorMsg);
|
||||
|
||||
this.status = {
|
||||
running: false,
|
||||
port: config?.port || DEFAULT_CONFIG.port,
|
||||
error: errorMsg,
|
||||
};
|
||||
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure server is running, starting if necessary
|
||||
* This is the main entry point for ensuring availability
|
||||
*/
|
||||
async ensureRunning(config?: VikingServerConfig): Promise<boolean> {
|
||||
const status = await this.getStatus();
|
||||
|
||||
if (status.running) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const startResult = await this.start(config);
|
||||
return startResult.running;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server URL for HTTP client connections
|
||||
*/
|
||||
getServerUrl(port?: number): string {
|
||||
const actualPort = port || this.status?.port || DEFAULT_CONFIG.port;
|
||||
return `http://127.0.0.1:${actualPort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is available (cached status)
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.status?.running ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached status (force refresh on next call)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.status = null;
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: VikingServerManager | null = null;
|
||||
|
||||
export function getVikingServerManager(): VikingServerManager {
|
||||
if (!_instance) {
|
||||
_instance = new VikingServerManager();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetVikingServerManager(): void {
|
||||
_instance = null;
|
||||
}
|
||||
426
docs/OPENVIKING_INTEGRATION.md
Normal file
426
docs/OPENVIKING_INTEGRATION.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# OpenViking 深度集成文档
|
||||
|
||||
## 概述
|
||||
|
||||
ZCLAW 桌面端已集成 OpenViking 记忆系统,支持三种运行模式:
|
||||
1. **本地服务器模式**:自动管理本地 OpenViking 服务器(隐私优先,数据完全本地)
|
||||
2. **远程模式**:连接到运行中的远程 OpenViking 服务器
|
||||
3. **本地存储模式**:使用 localStorage 作为回退(无需外部依赖)
|
||||
|
||||
**推荐**:对于注重隐私的用户,使用本地服务器模式,所有数据存储在 `~/.openviking/`。
|
||||
|
||||
## OpenViking 架构说明
|
||||
|
||||
OpenViking 采用客户端-服务器架构:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenViking 架构 │
|
||||
│ │
|
||||
│ ┌─────────────────┐ HTTP API ┌─────────────────┐ │
|
||||
│ │ ov CLI │ ◄──────────────────► │ openviking- │ │
|
||||
│ │ (Rust) │ │ server (Python) │ │
|
||||
│ └─────────────────┘ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ SQLite + Vector │ │
|
||||
│ │ ~/.openviking/ │ │
|
||||
│ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**重要**:CLI 不能独立运行,必须与服务器配合使用。
|
||||
|
||||
## ZCLAW 集成架构(本地模式)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ZCLAW Desktop (Tauri + React) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React UI Layer │ │
|
||||
│ │ ┌──────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ MemoryPanel │ │ContextBuilder │ │ │
|
||||
│ │ └──────┬───────┘ └───────┬────────┘ │ │
|
||||
│ └─────────┼─────────────────┼─────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────▼─────────────────▼─────────────────────────────────┐ │
|
||||
│ │ TypeScript Integration Layer │ │
|
||||
│ │ ┌─────────────────┐ ┌──────────────────────────────────┐ │ │
|
||||
│ │ │ VikingAdapter │ │ viking-server-manager │ │ │
|
||||
│ │ │ (local mode) │ │ (auto-start local server) │ │ │
|
||||
│ │ └────────┬────────┘ └──────────────────────────────────┘ │ │
|
||||
│ └───────────┼──────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────────────────────────────────────────────┐ │
|
||||
│ │ Tauri Command Layer │ │
|
||||
│ │ ┌──────────────────────────────────────────────────────────┐│ │
|
||||
│ │ │ viking_server_start/stop/status/restart ││ │
|
||||
│ │ │ (Rust: manages openviking-server process) ││ │
|
||||
│ │ └──────────────────────────────────────────────────────────┘│ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ Storage Layer (LOCAL DATA ONLY) │ │
|
||||
│ │ ┌──────────────────────────────────────────────────────────┐│ │
|
||||
│ │ │ OpenViking Server (Python) ││ │
|
||||
│ │ │ http://127.0.0.1:1933 ││ │
|
||||
│ │ │ Data: ~/.openviking/ ││ │
|
||||
│ │ │ - SQLite database ││ │
|
||||
│ │ │ - Vector embeddings ││ │
|
||||
│ │ │ - Configuration ││ │
|
||||
│ │ └──────────────────────────────────────────────────────────┘│ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 隐私保证
|
||||
|
||||
**本地模式下**:
|
||||
- ✅ 所有数据存储在 `~/.openviking/` 目录
|
||||
- ✅ 服务器只监听 `127.0.0.1`(本地回环)
|
||||
- ✅ 无任何数据上传到远程服务器
|
||||
- ✅ 向量嵌入通过 doubao API 生成(可选配置本地模型)
|
||||
|
||||
## 文件结构
|
||||
|
||||
### Rust 后端 (`desktop/src-tauri/src/`)
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `viking_commands.rs` | Tauri 命令封装,调用 OpenViking CLI |
|
||||
| `memory/mod.rs` | 记忆模块入口 |
|
||||
| `memory/extractor.rs` | LLM 驱动的会话记忆提取 |
|
||||
| `memory/context_builder.rs` | L0/L1/L2 分层上下文构建 |
|
||||
| `llm/mod.rs` | 多提供商 LLM 客户端 (doubao/OpenAI/Anthropic) |
|
||||
|
||||
### TypeScript 前端 (`desktop/src/lib/`)
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `viking-adapter.ts` | 多模式适配器 (local/sidecar/remote) |
|
||||
| `viking-server-manager.ts` | 本地服务器管理(启动/停止/状态) |
|
||||
| `viking-client.ts` | OpenViking HTTP API 客户端 |
|
||||
| `viking-local.ts` | Tauri sidecar 客户端 |
|
||||
| `viking-memory-adapter.ts` | VikingAdapter → MemoryManager 桥接 |
|
||||
| `context-builder.ts` | 聊天上下文构建器 |
|
||||
|
||||
### Rust 后端 (`desktop/src-tauri/src/`)
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `viking_server.rs` | **本地服务器管理** (启动/停止/状态检查) |
|
||||
| `viking_commands.rs` | Tauri 命令封装(调用 OpenViking CLI) |
|
||||
|
||||
### 二进制文件 (`desktop/src-tauri/binaries/`)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `ov-x86_64-pc-windows-msvc.exe` | Windows mock 二进制 (开发用) |
|
||||
| `README.md` | 获取真实二进制的说明 |
|
||||
|
||||
## L0/L1/L2 分层上下文加载
|
||||
|
||||
为了优化 Token 消耗,上下文构建采用三层加载策略:
|
||||
|
||||
| 层级 | 名称 | Token 预算 | 策略 |
|
||||
|------|------|-----------|------|
|
||||
| L0 | Quick Scan | ~500 | 快速向量搜索,返回概览 |
|
||||
| L1 | Standard | ~2000 | 加载相关项的详细内容 |
|
||||
| L2 | Deep | ~3000 | 加载最相关项的完整内容 |
|
||||
|
||||
```
|
||||
L0: find(query, limit=50) → 返回 URI + score + overview
|
||||
↓
|
||||
L1: read(uri, level=L1) → 返回详细内容 (score >= 0.5)
|
||||
↓
|
||||
L2: read(uri, level=L2) → 返回完整内容 (top 3)
|
||||
```
|
||||
|
||||
## LLM 记忆提取
|
||||
|
||||
会话结束后,LLM 自动分析并提取记忆:
|
||||
|
||||
```rust
|
||||
pub enum ExtractionCategory {
|
||||
UserPreference, // 用户偏好
|
||||
UserFact, // 用户事实
|
||||
AgentLesson, // Agent 经验教训
|
||||
AgentPattern, // Agent 任务模式
|
||||
Task, // 任务信息
|
||||
}
|
||||
```
|
||||
|
||||
### 支持的 LLM 提供商
|
||||
|
||||
| 提供商 | Endpoint | 默认模型 |
|
||||
|--------|----------|----------|
|
||||
| doubao | https://ark.cn-beijing.volces.com/api/v3 | doubao-pro-32k |
|
||||
| openai | https://api.openai.com/v1 | gpt-4o |
|
||||
| anthropic | https://api.anthropic.com/v1 | claude-sonnet-4-20250514 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 本地服务器模式 (推荐,隐私优先)
|
||||
|
||||
```typescript
|
||||
import { getVikingAdapter } from './lib/viking-adapter';
|
||||
import { getVikingServerManager } from './lib/viking-server-manager';
|
||||
|
||||
// 获取服务器管理器
|
||||
const serverManager = getVikingServerManager();
|
||||
|
||||
// 确保本地服务器运行
|
||||
await serverManager.ensureRunning();
|
||||
|
||||
// 使用适配器(自动检测本地服务器)
|
||||
const viking = getVikingAdapter({ mode: 'auto' });
|
||||
await viking.buildEnhancedContext(userMessage, agentId);
|
||||
|
||||
// 检查服务器状态
|
||||
const status = await serverManager.getStatus();
|
||||
console.log(`Server running: ${status.running}, port: ${status.port}`);
|
||||
```
|
||||
|
||||
### 2. 自动模式 (智能检测)
|
||||
|
||||
```typescript
|
||||
const viking = getVikingAdapter(); // mode: 'auto'
|
||||
await viking.buildEnhancedContext(userMessage, agentId);
|
||||
```
|
||||
|
||||
自动检测顺序:
|
||||
1. 尝试启动本地服务器 (local)
|
||||
2. 检查 sidecar CLI (sidecar)
|
||||
3. 连接远程服务器 (remote)
|
||||
4. 回退到 localStorage
|
||||
|
||||
### 3. 强制本地模式
|
||||
|
||||
```typescript
|
||||
const viking = getVikingAdapter({ mode: 'local' });
|
||||
```
|
||||
|
||||
### 4. 强制 Sidecar 模式
|
||||
|
||||
```typescript
|
||||
const viking = getVikingAdapter({ mode: 'sidecar' });
|
||||
```
|
||||
|
||||
### 5. 使用 MemoryManager 接口
|
||||
|
||||
```typescript
|
||||
import { getVikingMemoryAdapter } from './lib/viking-memory-adapter';
|
||||
|
||||
const adapter = getVikingMemoryAdapter({
|
||||
enabled: true,
|
||||
mode: 'auto',
|
||||
fallbackToLocal: true
|
||||
});
|
||||
|
||||
// 使用与 agent-memory.ts 相同的接口
|
||||
await adapter.save(entry);
|
||||
await adapter.search(query);
|
||||
await adapter.stats(agentId);
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### 本地服务器配置 (Tauri 命令)
|
||||
|
||||
通过 Rust 后端管理本地 OpenViking 服务器:
|
||||
|
||||
```typescript
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// 获取服务器状态
|
||||
const status = await invoke<VikingServerStatus>('viking_server_status');
|
||||
|
||||
// 启动服务器
|
||||
await invoke<VikingServerStatus>('viking_server_start', {
|
||||
config: {
|
||||
port: 1933,
|
||||
dataDir: '', // 使用默认 ~/.openviking/workspace
|
||||
configFile: '' // 使用默认 ~/.openviking/ov.conf
|
||||
}
|
||||
});
|
||||
|
||||
// 停止服务器
|
||||
await invoke('viking_server_stop');
|
||||
|
||||
// 重启服务器
|
||||
await invoke<VikingServerStatus>('viking_server_restart');
|
||||
```
|
||||
|
||||
### Tauri Sidecar 配置 (`tauri.conf.json`)
|
||||
|
||||
仅在 sidecar 模式下需要:
|
||||
|
||||
```json
|
||||
{
|
||||
"bundle": {
|
||||
"externalBin": ["binaries/ov"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `ZCLAW_VIKING_BIN` | OpenViking CLI 二进制路径 (sidecar 模式) |
|
||||
| `ZCLAW_VIKING_SERVER_BIN` | OpenViking 服务器二进制路径 (本地模式) |
|
||||
| `VIKING_SERVER_URL` | 远程服务器地址 (远程模式) |
|
||||
| `OPENVIKING_CONFIG_FILE` | OpenViking 配置文件路径 |
|
||||
|
||||
## 安装 OpenViking (本地模式)
|
||||
|
||||
### 系统要求
|
||||
|
||||
- Python 3.10+
|
||||
- 可选: Go 1.22+ (用于构建 AGFS 组件)
|
||||
- 可选: C++ 编译器: GCC 9+ 或 Clang 11+
|
||||
|
||||
### 快速安装 (推荐)
|
||||
|
||||
ZCLAW 会自动管理本地 OpenViking 服务器。你只需要安装 OpenViking Python 包:
|
||||
|
||||
```bash
|
||||
# 使用 pip 安装
|
||||
pip install openviking --upgrade --force-reinstall
|
||||
|
||||
# 验证安装
|
||||
openviking-server --version
|
||||
```
|
||||
|
||||
### 自动服务器管理
|
||||
|
||||
ZCLAW 的 `viking-server-manager.ts` 会自动:
|
||||
1. 检测本地服务器是否运行
|
||||
2. 如未运行,自动启动 `openviking-server`
|
||||
3. 监控服务器健康状态
|
||||
4. 在应用退出时清理进程
|
||||
|
||||
```typescript
|
||||
import { getVikingServerManager } from './lib/viking-server-manager';
|
||||
|
||||
const manager = getVikingServerManager();
|
||||
|
||||
// 确保服务器运行(自动启动如果需要)
|
||||
await manager.ensureRunning();
|
||||
|
||||
// 获取服务器状态
|
||||
const status = await manager.getStatus();
|
||||
// { running: true, port: 1933, pid: 12345, dataDir: '~/.openviking/workspace' }
|
||||
|
||||
// 获取服务器 URL
|
||||
const url = manager.getServerUrl(); // 'http://127.0.0.1:1933'
|
||||
```
|
||||
|
||||
### 手动启动服务器 (可选)
|
||||
|
||||
如果你希望手动控制服务器:
|
||||
|
||||
```bash
|
||||
# 前台运行
|
||||
openviking-server --host 127.0.0.1 --port 1933
|
||||
|
||||
# 后台运行 (Linux/macOS)
|
||||
nohup openviking-server > ~/.openviking/server.log 2>&1 &
|
||||
|
||||
# 后台运行 (Windows PowerShell)
|
||||
Start-Process -NoNewWindow openviking-server -RedirectStandardOutput "$env:USERPROFILE\.openviking\server.log"
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
创建 `~/.openviking/ov.conf`:
|
||||
|
||||
```json
|
||||
{
|
||||
"storage": {
|
||||
"workspace": "/home/your-name/openviking_workspace"
|
||||
},
|
||||
"embedding": {
|
||||
"dense": {
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"api_key": "your-api-key",
|
||||
"provider": "volcengine",
|
||||
"dimension": 1024,
|
||||
"model": "doubao-embedding-vision-250615"
|
||||
}
|
||||
},
|
||||
"vlm": {
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"api_key": "your-api-key",
|
||||
"provider": "volcengine",
|
||||
"model": "doubao-seed-2-0-pro-260215"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
设置环境变量:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
export OPENVIKING_CONFIG_FILE=~/.openviking/ov.conf
|
||||
|
||||
# Windows PowerShell
|
||||
$env:OPENVIKING_CONFIG_FILE = "$HOME/.openviking/ov.conf"
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
### Rust 测试
|
||||
|
||||
```bash
|
||||
cd desktop/src-tauri
|
||||
cargo test
|
||||
```
|
||||
|
||||
当前测试覆盖:
|
||||
- `test_provider_configs` - LLM 提供商配置
|
||||
- `test_llm_client_creation` - LLM 客户端创建
|
||||
- `test_extraction_config_default` - 提取配置默认值
|
||||
- `test_uri_generation` - URI 生成
|
||||
- `test_estimate_tokens` - Token 估算
|
||||
- `test_extract_category` - 分类提取
|
||||
- `test_context_builder_config_default` - 上下文构建器配置
|
||||
- `test_status_unavailable_without_cli` - 无 CLI 时的状态
|
||||
|
||||
### TypeScript 测试
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
pnpm vitest run tests/desktop/memory*.test.ts
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### Q: OpenViking CLI not found
|
||||
|
||||
确保 `ov-x86_64-pc-windows-msvc.exe` 存在于 `binaries/` 目录,或设置 `ZCLAW_VIKING_BIN` 环境变量。
|
||||
|
||||
### Q: Sidecar 启动失败
|
||||
|
||||
检查 Tauri 控制台日志,确认 sidecar 二进制权限正确。
|
||||
|
||||
### Q: 记忆未保存
|
||||
|
||||
1. 检查 LLM API 配置 (doubao/OpenAI/Anthropic)
|
||||
2. 确认 `VIKING_SERVER_URL` 正确 (remote 模式)
|
||||
3. 检查浏览器控制台的网络请求
|
||||
|
||||
## 迁移路径
|
||||
|
||||
从现有 localStorage 实现迁移到 OpenViking:
|
||||
|
||||
1. 导出现有记忆:`getMemoryManager().exportToMarkdown(agentId)`
|
||||
2. 切换到 OpenViking 模式
|
||||
3. 导入记忆到 OpenViking (通过 CLI 或 API)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [OpenViking GitHub](https://github.com/anthropics/openviking)
|
||||
- [ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md](../docs/ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md)
|
||||
- [ZCLAW_OPENVIKING_INTEGRATION_PLAN.md](../docs/ZCLAW_OPENVIKING_INTEGRATION_PLAN.md)
|
||||
Reference in New Issue
Block a user