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;
|
||||
}
|
||||
Reference in New Issue
Block a user