Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- 新增 66 个 @reserved 标注 (已有 22 个) - 覆盖: agent/butler/classroom/hand/mcp/pipeline/skill/trigger/viking/zclaw 等模块 - MCP 命令增加 @connected 注释说明前端接入路径 - @reserved 总数: 89 (含 identity_init)
420 lines
15 KiB
Rust
420 lines
15 KiB
Rust
//! Kernel lifecycle commands: init, status, shutdown
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::State;
|
|
|
|
use super::{KernelState, SchedulerState};
|
|
use crate::intelligence::heartbeat::{HeartbeatEngine, HeartbeatEngineState};
|
|
|
|
const DEFAULT_HEARTBEAT_AGENT: &str = "zclaw-main";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request / Response types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn default_api_protocol() -> String { "openai".to_string() }
|
|
fn default_kernel_provider() -> String { "openai".to_string() }
|
|
fn default_kernel_model() -> String { "gpt-4o-mini".to_string() }
|
|
|
|
/// Kernel configuration request
|
|
///
|
|
/// Simple configuration: base_url + api_key + model
|
|
/// Model ID is passed directly to the API without any transformation
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct KernelConfigRequest {
|
|
/// LLM provider (for preset URLs): anthropic, openai, zhipu, kimi, qwen, deepseek, local, custom
|
|
#[serde(default = "default_kernel_provider")]
|
|
pub provider: String,
|
|
/// Model identifier - passed directly to the API
|
|
#[serde(default = "default_kernel_model")]
|
|
pub model: String,
|
|
/// API key
|
|
pub api_key: Option<String>,
|
|
/// Base URL (optional, uses provider default if not specified)
|
|
pub base_url: Option<String>,
|
|
/// API protocol: openai or anthropic
|
|
#[serde(default = "default_api_protocol")]
|
|
pub api_protocol: String,
|
|
}
|
|
|
|
/// Kernel status response
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct KernelStatusResponse {
|
|
pub initialized: bool,
|
|
pub agent_count: usize,
|
|
pub database_url: Option<String>,
|
|
pub base_url: Option<String>,
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Initialize the internal ZCLAW Kernel
|
|
///
|
|
/// If kernel already exists with the same config, returns existing status.
|
|
/// If config changed, reboots kernel with new config.
|
|
// @reserved: kernel lifecycle management
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn kernel_init(
|
|
state: State<'_, KernelState>,
|
|
scheduler_state: State<'_, SchedulerState>,
|
|
heartbeat_state: State<'_, HeartbeatEngineState>,
|
|
mcp_state: State<'_, crate::kernel_commands::mcp::McpManagerState>,
|
|
config_request: Option<KernelConfigRequest>,
|
|
) -> Result<KernelStatusResponse, String> {
|
|
let mut kernel_lock = state.lock().await;
|
|
|
|
// Check if we need to reboot kernel with new config
|
|
if let Some(kernel) = kernel_lock.as_ref() {
|
|
// Get current config from kernel
|
|
let current_config = kernel.config();
|
|
|
|
// Check if config changed (model, base_url, or api_key)
|
|
let config_changed = if let Some(ref req) = config_request {
|
|
let default_base_url = zclaw_kernel::config::KernelConfig::from_provider(
|
|
&req.provider, "", &req.model, None, &req.api_protocol
|
|
).llm.base_url;
|
|
let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone());
|
|
let current_api_key = ¤t_config.llm.api_key;
|
|
let request_api_key = req.api_key.as_deref().unwrap_or("");
|
|
|
|
current_config.llm.model != req.model ||
|
|
current_config.llm.base_url != request_base_url ||
|
|
current_api_key != request_api_key
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if !config_changed {
|
|
// Same config, return existing status
|
|
return Ok(KernelStatusResponse {
|
|
initialized: true,
|
|
agent_count: kernel.list_agents().len(),
|
|
database_url: None,
|
|
base_url: Some(current_config.llm.base_url.clone()),
|
|
model: Some(current_config.llm.model.clone()),
|
|
});
|
|
}
|
|
|
|
// Config changed, need to reboot kernel
|
|
// Shutdown old kernel
|
|
if let Err(e) = kernel.shutdown().await {
|
|
tracing::warn!("[kernel_init] Failed to shutdown old kernel: {}", e);
|
|
}
|
|
*kernel_lock = None;
|
|
}
|
|
|
|
// Build configuration from request
|
|
let config = if let Some(req) = &config_request {
|
|
let api_key = req.api_key.as_deref().unwrap_or("");
|
|
let base_url = req.base_url.as_deref();
|
|
|
|
zclaw_kernel::config::KernelConfig::from_provider(
|
|
&req.provider,
|
|
api_key,
|
|
&req.model,
|
|
base_url,
|
|
&req.api_protocol,
|
|
)
|
|
} else {
|
|
zclaw_kernel::config::KernelConfig::default()
|
|
};
|
|
|
|
// Debug: print skills directory
|
|
if let Some(ref skills_dir) = config.skills_dir {
|
|
tracing::debug!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists());
|
|
} else {
|
|
tracing::debug!("[kernel_init] No skills directory configured");
|
|
}
|
|
|
|
let base_url = config.llm.base_url.clone();
|
|
let model = config.llm.model.clone();
|
|
|
|
// Boot kernel
|
|
let mut kernel = zclaw_kernel::Kernel::boot(config.clone())
|
|
.await
|
|
.map_err(|e| format!("Failed to initialize kernel: {}", e))?;
|
|
|
|
let agent_count = kernel.list_agents().len();
|
|
|
|
// Configure extraction driver so the Growth system can call LLM for memory extraction
|
|
let driver = kernel.driver();
|
|
crate::intelligence::extraction_adapter::configure_extraction_driver(
|
|
driver.clone(),
|
|
model.clone(),
|
|
);
|
|
|
|
// Bridge SqliteStorage to Kernel's GrowthIntegration
|
|
{
|
|
match crate::viking_commands::get_storage().await {
|
|
Ok(sqlite_storage) => {
|
|
let viking = std::sync::Arc::new(zclaw_runtime::VikingAdapter::new(sqlite_storage));
|
|
kernel.set_viking(viking);
|
|
tracing::info!("[kernel_init] Bridged persistent SqliteStorage to Kernel GrowthIntegration");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"[kernel_init] Failed to get SqliteStorage, GrowthIntegration will use in-memory storage: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Set the LLM extraction driver on the kernel for memory extraction via middleware
|
|
let extraction_driver = crate::intelligence::extraction_adapter::TauriExtractionDriver::new(
|
|
driver.clone(),
|
|
model.clone(),
|
|
);
|
|
kernel.set_extraction_driver(std::sync::Arc::new(extraction_driver));
|
|
}
|
|
|
|
// Bridge MCP adapters — kernel reads from this shared list during
|
|
// create_tool_registry() so the LLM can discover MCP tools.
|
|
// Share the McpManagerState's Arc with the Kernel so both point to the same list.
|
|
{
|
|
let shared_arc = mcp_state.kernel_adapters.clone();
|
|
// Copy any adapters already in the kernel's default list into the shared one
|
|
let kernel_default = kernel.mcp_adapters();
|
|
if let (Ok(src), Ok(mut dst)) = (kernel_default.read(), shared_arc.write()) {
|
|
*dst = src.clone();
|
|
}
|
|
kernel.set_mcp_adapters(shared_arc);
|
|
tracing::info!("[kernel_init] Bridged MCP adapters to Kernel for LLM tool discovery");
|
|
}
|
|
|
|
// Configure summary driver so the Growth system can generate L0/L1 summaries
|
|
if let Some(api_key) = config_request.as_ref().and_then(|r| r.api_key.clone()) {
|
|
crate::summarizer_adapter::configure_summary_driver(
|
|
crate::summarizer_adapter::TauriSummaryDriver::new(
|
|
format!("{}/chat/completions", base_url),
|
|
api_key,
|
|
Some(model.clone()),
|
|
),
|
|
);
|
|
}
|
|
|
|
*kernel_lock = Some(kernel);
|
|
|
|
// Start SchedulerService — periodically checks and fires scheduled triggers
|
|
{
|
|
let mut sched_lock = scheduler_state.lock().await;
|
|
// Stop old scheduler if any
|
|
if let Some(ref old) = *sched_lock {
|
|
old.stop();
|
|
}
|
|
let scheduler = zclaw_kernel::scheduler::SchedulerService::new(
|
|
state.inner().clone(),
|
|
60, // check every 60 seconds
|
|
);
|
|
scheduler.start();
|
|
tracing::info!("[kernel_init] SchedulerService started (60s interval)");
|
|
*sched_lock = Some(scheduler);
|
|
}
|
|
|
|
// Auto-initialize heartbeat engine for the default agent
|
|
{
|
|
let mut engines = heartbeat_state.lock().await;
|
|
if !engines.contains_key(DEFAULT_HEARTBEAT_AGENT) {
|
|
let agent_id = DEFAULT_HEARTBEAT_AGENT.to_string();
|
|
let engine = HeartbeatEngine::new(agent_id.clone(), None);
|
|
crate::intelligence::heartbeat::restore_last_interaction(&agent_id).await;
|
|
engine.restore_history().await;
|
|
engine.start().await;
|
|
engines.insert(agent_id, engine);
|
|
tracing::info!("[kernel_init] Heartbeat engine auto-initialized and started for '{}'", DEFAULT_HEARTBEAT_AGENT);
|
|
}
|
|
}
|
|
|
|
Ok(KernelStatusResponse {
|
|
initialized: true,
|
|
agent_count,
|
|
database_url: Some(config.database_url),
|
|
base_url: Some(base_url),
|
|
model: Some(model),
|
|
})
|
|
}
|
|
|
|
/// Get kernel status
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn kernel_status(
|
|
state: State<'_, KernelState>,
|
|
) -> Result<KernelStatusResponse, String> {
|
|
let kernel_lock = state.lock().await;
|
|
|
|
match kernel_lock.as_ref() {
|
|
Some(kernel) => Ok(KernelStatusResponse {
|
|
initialized: true,
|
|
agent_count: kernel.list_agents().len(),
|
|
database_url: Some(kernel.config().database_url.clone()),
|
|
base_url: Some(kernel.config().llm.base_url.clone()),
|
|
model: Some(kernel.config().llm.model.clone()),
|
|
}),
|
|
None => Ok(KernelStatusResponse {
|
|
initialized: false,
|
|
agent_count: 0,
|
|
database_url: None,
|
|
base_url: None,
|
|
model: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// @reserved — no frontend UI yet
|
|
/// Shutdown the kernel
|
|
#[tauri::command]
|
|
pub async fn kernel_shutdown(
|
|
state: State<'_, KernelState>,
|
|
scheduler_state: State<'_, SchedulerState>,
|
|
) -> Result<(), String> {
|
|
// Stop scheduler first
|
|
{
|
|
let mut sched_lock = scheduler_state.lock().await;
|
|
if let Some(scheduler) = sched_lock.take() {
|
|
scheduler.stop();
|
|
tracing::info!("[kernel_shutdown] SchedulerService stopped");
|
|
}
|
|
}
|
|
|
|
let mut kernel_lock = state.lock().await;
|
|
|
|
if let Some(kernel) = kernel_lock.take() {
|
|
kernel.shutdown().await.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply SaaS-synced configuration to the Kernel config file.
|
|
///
|
|
/// Writes relevant config values (agent, llm categories) to the TOML config file.
|
|
/// The changes take effect on the next Kernel restart.
|
|
// @connected
|
|
#[tauri::command]
|
|
pub async fn kernel_apply_saas_config(
|
|
configs: Vec<SaasConfigItem>,
|
|
) -> Result<u32, String> {
|
|
use std::io::Write;
|
|
|
|
let config_path = zclaw_kernel::config::KernelConfig::find_config_path()
|
|
.ok_or_else(|| "No config file path found".to_string())?;
|
|
|
|
// Read existing config or create empty
|
|
let existing = if config_path.exists() {
|
|
std::fs::read_to_string(&config_path).unwrap_or_default()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let mut updated = existing;
|
|
let mut applied: u32 = 0;
|
|
|
|
for config in &configs {
|
|
// Only process kernel-relevant categories
|
|
if !matches!(config.category.as_str(), "agent" | "llm") {
|
|
continue;
|
|
}
|
|
|
|
// Write key=value to the [llm] or [agent] section
|
|
let section = &config.category;
|
|
let key = config.key.replace('.', "_");
|
|
let value = &config.value;
|
|
|
|
// Simple TOML patching: find or create section, update key
|
|
let section_header = format!("[{}]", section);
|
|
let line_to_set = format!("{} = {}", key, toml_quote_value(value));
|
|
|
|
if let Some(section_start) = updated.find(§ion_header) {
|
|
// Section exists, find or add the key within it
|
|
let after_header = section_start + section_header.len();
|
|
let next_section = updated[after_header..].find("\n[")
|
|
.map(|i| after_header + i)
|
|
.unwrap_or(updated.len());
|
|
|
|
let section_content = &updated[after_header..next_section];
|
|
let key_prefix = format!("\n{} =", key);
|
|
let key_prefix_alt = format!("\n{}=", key);
|
|
|
|
if let Some(key_pos) = section_content.find(&key_prefix)
|
|
.or_else(|| section_content.find(&key_prefix_alt))
|
|
{
|
|
// Key exists, replace the line
|
|
let line_start = after_header + key_pos + 1; // skip \n
|
|
let line_end = updated[line_start..].find('\n')
|
|
.map(|i| line_start + i)
|
|
.unwrap_or(updated.len());
|
|
updated = format!(
|
|
"{}{}{}\n{}",
|
|
&updated[..line_start],
|
|
line_to_set,
|
|
if line_end < updated.len() { "" } else { "" },
|
|
&updated[line_end..]
|
|
);
|
|
// Remove the extra newline if line_end included one
|
|
updated = updated.replace(&format!("{}\n\n", line_to_set), &format!("{}\n", line_to_set));
|
|
} else {
|
|
// Key doesn't exist, append to section
|
|
updated.insert_str(next_section, format!("\n{}", line_to_set).as_str());
|
|
}
|
|
} else {
|
|
// Section doesn't exist, append it
|
|
updated = format!("{}\n{}\n{}\n", updated.trim_end(), section_header, line_to_set);
|
|
}
|
|
applied += 1;
|
|
}
|
|
|
|
if applied > 0 {
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = config_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {}", e))?;
|
|
}
|
|
|
|
let mut file = std::fs::File::create(&config_path)
|
|
.map_err(|e| format!("Failed to write config: {}", e))?;
|
|
file.write_all(updated.as_bytes())
|
|
.map_err(|e| format!("Failed to write config: {}", e))?;
|
|
|
|
tracing::info!(
|
|
"[kernel_apply_saas_config] Applied {} config items to {:?} (restart required)",
|
|
applied,
|
|
config_path
|
|
);
|
|
}
|
|
|
|
Ok(applied)
|
|
}
|
|
|
|
/// Single config item from SaaS sync
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SaasConfigItem {
|
|
pub category: String,
|
|
pub key: String,
|
|
pub value: String,
|
|
}
|
|
|
|
/// Quote a value for TOML format
|
|
fn toml_quote_value(value: &str) -> String {
|
|
// Try to parse as number or boolean
|
|
if value == "true" || value == "false" {
|
|
return value.to_string();
|
|
}
|
|
if let Ok(n) = value.parse::<i64>() {
|
|
return n.to_string();
|
|
}
|
|
if let Ok(n) = value.parse::<f64>() {
|
|
return n.to_string();
|
|
}
|
|
// Handle multi-line strings with TOML triple-quote syntax
|
|
if value.contains('\n') {
|
|
return format!("\"\"\"\n{}\"\"\"", value.replace('\\', "\\\\").replace("\"\"\"", "'\"'\"'\""));
|
|
}
|
|
// Default: quote as string
|
|
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
|
}
|