feat: Batch 5-9 — GrowthIntegration桥接、验证补全、死代码清理、Pipeline模板、Speech/Twitter真实实现
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

Batch 5 (P0): GrowthIntegration 接入 Tauri
- Kernel 新增 set_viking()/set_extraction_driver() 桥接 SqliteStorage
- 中间件链共享存储,MemoryExtractor 接入 LLM 驱动

Batch 6 (P1): 输入验证 + Heartbeat
- Relay 验证补全(stream 兼容检查、API key 格式校验)
- UUID 类型校验、SessionId 错误返回
- Heartbeat 默认开启 + 首次聊天自动初始化

Batch 7 (P2): 死代码清理
- zclaw-channels 整体移除(317 行)
- multi-agent 特性门控、admin 方法标注

Batch 8 (P2): Pipeline 模板
- PipelineMetadata 新增 annotations 字段
- pipeline_templates 命令 + 2 个示例模板
- fallback driver base_url 修复(doubao/qwen/deepseek 端点)

Batch 9 (P1): SpeechHand/TwitterHand 真实实现
- SpeechHand: tts_method 字段 + Browser TTS 前端集成 (Web Speech API)
- TwitterHand: 12 个 action 全部替换为 Twitter API v2 真实 HTTP 调用
- chatStore/useAutomationEvents 双路径 TTS 触发
This commit is contained in:
iven
2026-03-30 09:24:50 +08:00
parent 5595083b96
commit 13c0b18bbc
39 changed files with 1155 additions and 507 deletions

View File

@@ -246,6 +246,7 @@ pub fn is_extraction_driver_configured() -> bool {
/// Get the global extraction driver.
///
/// Returns `None` if not yet configured via `configure_extraction_driver`.
#[allow(dead_code)]
pub fn get_extraction_driver() -> Option<Arc<TauriExtractionDriver>> {
EXTRACTION_DRIVER.get().cloned()
}

View File

@@ -100,12 +100,12 @@ pub type HeartbeatCheckFn = Box<dyn Fn(String) -> std::pin::Pin<Box<dyn std::fut
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
enabled: true,
interval_minutes: 30,
quiet_hours_start: Some("22:00".to_string()),
quiet_hours_end: Some("08:00".to_string()),
notify_channel: NotifyChannel::Ui,
proactivity_level: ProactivityLevel::Light,
proactivity_level: ProactivityLevel::Standard,
max_alerts_per_tick: 5,
}
}

View File

@@ -57,6 +57,52 @@ impl fmt::Display for ValidationError {
impl std::error::Error for ValidationError {}
/// Validate a UUID string (for agent_id, session_id, etc.)
///
/// Provides a clear error message when the UUID format is invalid,
/// instead of a generic "invalid characters" error from `validate_identifier`.
pub fn validate_uuid(value: &str, field_name: &str) -> Result<(), ValidationError> {
let len = value.len();
if len == 0 {
return Err(ValidationError::RequiredFieldEmpty {
field: field_name.to_string(),
});
}
// UUID format: 8-4-4-4-12 hex digits with hyphens (36 chars total)
if len != 36 {
return Err(ValidationError::InvalidCharacters {
field: field_name.to_string(),
invalid_chars: format!("expected UUID format (36 chars), got {} chars", len),
});
}
// Quick structure check: positions 8,13,18,23 should be '-'
let bytes = value.as_bytes();
if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
return Err(ValidationError::InvalidCharacters {
field: field_name.to_string(),
invalid_chars: "not a valid UUID (expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".into(),
});
}
// Check all non-hyphen positions are hex digits
for (i, &b) in bytes.iter().enumerate() {
if i == 8 || i == 13 || i == 18 || i == 23 {
continue;
}
if !b.is_ascii_hexdigit() {
return Err(ValidationError::InvalidCharacters {
field: field_name.to_string(),
invalid_chars: format!("'{}' at position {} is not a hex digit", b as char, i),
});
}
}
Ok(())
}
/// Validate an identifier (agent_id, pipeline_id, skill_id, etc.)
///
/// # Rules

View File

@@ -25,6 +25,11 @@ pub type SessionStreamGuard = Arc<dashmap::DashMap<String, Arc<Mutex<()>>>>;
fn validate_agent_id(agent_id: &str) -> Result<String, String> {
validate_identifier(agent_id, "agent_id")
.map_err(|e| format!("Invalid agent_id: {}", e))?;
// AgentId is a UUID wrapper — validate UUID format for better error messages
if agent_id.contains('-') {
crate::intelligence::validation::validate_uuid(agent_id, "agent_id")
.map_err(|e| format!("Invalid agent_id: {}", e))?;
}
Ok(agent_id.to_string())
}
@@ -209,7 +214,7 @@ pub async fn kernel_init(
let model = config.llm.model.clone();
// Boot kernel
let kernel = Kernel::boot(config.clone())
let mut kernel = Kernel::boot(config.clone())
.await
.map_err(|e| format!("Failed to initialize kernel: {}", e))?;
@@ -222,6 +227,33 @@ pub async fn kernel_init(
model.clone(),
);
// Bridge SqliteStorage to Kernel's GrowthIntegration
// This connects the middleware chain (MemoryMiddleware, CompactionMiddleware)
// to the same persistent SqliteStorage used by viking_commands and intelligence_hooks.
{
match crate::viking_commands::get_storage().await {
Ok(sqlite_storage) => {
// Wrap SqliteStorage in VikingAdapter (SqliteStorage implements VikingStorage)
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));
}
// 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(
@@ -530,6 +562,21 @@ pub async fn agent_chat_stream(
format!("Session {} already has an active stream", session_id)
})?;
// AUTO-INIT HEARTBEAT: Ensure heartbeat engine exists for this agent.
// Uses default config (enabled: true, 30min interval) so heartbeat runs
// automatically from the first conversation without manual setup.
{
let mut engines = heartbeat_state.lock().await;
if !engines.contains_key(&request.agent_id) {
let engine = crate::intelligence::heartbeat::HeartbeatEngine::new(
request.agent_id.clone(),
None, // Use default config (enabled: true)
);
engines.insert(request.agent_id.clone(), engine);
tracing::info!("[agent_chat_stream] Auto-initialized heartbeat for agent: {}", request.agent_id);
}
}
// PRE-CONVERSATION: Build intelligence-enhanced system prompt
let enhanced_prompt = crate::intelligence_hooks::pre_conversation_hook(
&request.agent_id,
@@ -550,15 +597,22 @@ pub async fn agent_chat_stream(
// Use intelligence-enhanced system prompt if available
let prompt_arg = if enhanced_prompt.is_empty() { None } else { Some(enhanced_prompt) };
// Parse session_id for session reuse (carry conversation history across turns)
let session_id_parsed = std::str::FromStr::from_str(&session_id)
.ok()
.map(|uuid| zclaw_types::SessionId::from_uuid(uuid));
if session_id_parsed.is_none() {
tracing::warn!(
"session_id '{}' is not a valid UUID, will create a new session (context will be lost)",
session_id
);
}
// Empty session_id means first message in a new conversation — that's valid.
// Non-empty session_id MUST be a valid UUID; if not, return error instead of
// silently losing context by creating a new session.
let session_id_parsed = if session_id.is_empty() {
None
} else {
match uuid::Uuid::parse_str(&session_id) {
Ok(uuid) => Some(zclaw_types::SessionId::from_uuid(uuid)),
Err(e) => {
return Err(format!(
"Invalid session_id '{}': {}. Cannot reuse conversation context.",
session_id, e
));
}
}
};
let rx = kernel.send_message_stream_with_prompt(&id, message.clone(), prompt_arg, session_id_parsed)
.await
.map_err(|e| format!("Failed to start streaming: {}", e))?;
@@ -1775,9 +1829,10 @@ pub async fn scheduled_task_list(
}
// ============================================================
// A2A (Agent-to-Agent) Commands
// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature
// ============================================================
#[cfg(feature = "multi-agent")]
/// Send a direct A2A message from one agent to another
#[tauri::command]
pub async fn agent_a2a_send(
@@ -1810,6 +1865,7 @@ pub async fn agent_a2a_send(
}
/// Broadcast a message from one agent to all other agents
#[cfg(feature = "multi-agent")]
#[tauri::command]
pub async fn agent_a2a_broadcast(
state: State<'_, KernelState>,
@@ -1830,6 +1886,7 @@ pub async fn agent_a2a_broadcast(
}
/// Discover agents with a specific capability
#[cfg(feature = "multi-agent")]
#[tauri::command]
pub async fn agent_a2a_discover(
state: State<'_, KernelState>,
@@ -1850,6 +1907,7 @@ pub async fn agent_a2a_discover(
}
/// Delegate a task to another agent and wait for response
#[cfg(feature = "multi-agent")]
#[tauri::command]
pub async fn agent_a2a_delegate_task(
state: State<'_, KernelState>,

View File

@@ -1352,15 +1352,19 @@ pub fn run() {
kernel_commands::scheduled_task_create,
kernel_commands::scheduled_task_list,
// A2A commands (Agent-to-Agent messaging)
// A2A commands gated behind multi-agent feature
#[cfg(feature = "multi-agent")]
kernel_commands::agent_a2a_send,
#[cfg(feature = "multi-agent")]
kernel_commands::agent_a2a_broadcast,
#[cfg(feature = "multi-agent")]
kernel_commands::agent_a2a_discover,
#[cfg(feature = "multi-agent")]
kernel_commands::agent_a2a_delegate_task,
// Pipeline commands (DSL-based workflows)
pipeline_commands::pipeline_list,
pipeline_commands::pipeline_get,
pipeline_commands::pipeline_templates, pipeline_commands::pipeline_get,
pipeline_commands::pipeline_run,
pipeline_commands::pipeline_progress,
pipeline_commands::pipeline_cancel,

View File

@@ -681,9 +681,8 @@ fn scan_pipelines_with_paths(
tracing::debug!("[scan] File content length: {} bytes", content.len());
match parse_pipeline_yaml(&content) {
Ok(pipeline) => {
// Debug: log parsed pipeline metadata
println!(
"[DEBUG scan] Parsed YAML: {} -> category: {:?}, industry: {:?}",
tracing::debug!(
"[scan] Parsed YAML: {} -> category: {:?}, industry: {:?}",
pipeline.metadata.name,
pipeline.metadata.category,
pipeline.metadata.industry
@@ -744,8 +743,8 @@ fn scan_pipelines_full_sync(
fn pipeline_to_info(pipeline: &Pipeline) -> PipelineInfo {
let industry = pipeline.metadata.industry.clone().unwrap_or_default();
println!(
"[DEBUG pipeline_to_info] Pipeline: {}, category: {:?}, industry: {:?}",
tracing::debug!(
"[pipeline_to_info] Pipeline: {}, category: {:?}, industry: {:?}",
pipeline.metadata.name,
pipeline.metadata.category,
pipeline.metadata.industry
@@ -1040,16 +1039,30 @@ fn create_llm_driver_from_config() -> Option<Arc<dyn LlmActionDriver>> {
// Convert api_key to SecretString
let secret_key = SecretString::new(api_key);
// Create the runtime driver
// Create the runtime driver — use with_base_url when a custom endpoint is configured
// (essential for Chinese providers like doubao, qwen, deepseek, kimi)
let runtime_driver: Arc<dyn zclaw_runtime::LlmDriver> = match provider.as_str() {
"anthropic" => {
Arc::new(zclaw_runtime::AnthropicDriver::new(secret_key))
if let Some(url) = base_url {
Arc::new(zclaw_runtime::AnthropicDriver::with_base_url(secret_key, url))
} else {
Arc::new(zclaw_runtime::AnthropicDriver::new(secret_key))
}
}
"openai" | "doubao" | "qwen" | "deepseek" | "kimi" => {
Arc::new(zclaw_runtime::OpenAiDriver::new(secret_key))
"openai" | "doubao" | "qwen" | "deepseek" | "kimi" | "zhipu" => {
// Chinese providers typically need a custom base_url
if let Some(url) = base_url {
Arc::new(zclaw_runtime::OpenAiDriver::with_base_url(secret_key, url))
} else {
Arc::new(zclaw_runtime::OpenAiDriver::new(secret_key))
}
}
"gemini" => {
Arc::new(zclaw_runtime::GeminiDriver::new(secret_key))
if let Some(url) = base_url {
Arc::new(zclaw_runtime::GeminiDriver::with_base_url(secret_key, url))
} else {
Arc::new(zclaw_runtime::GeminiDriver::new(secret_key))
}
}
"local" | "ollama" => {
let url = base_url.unwrap_or_else(|| "http://localhost:11434".to_string());
@@ -1077,3 +1090,83 @@ pub async fn analyze_presentation(
// Convert analysis to JSON
serde_json::to_value(&analysis).map_err(|e| e.to_string())
}
/// Pipeline template metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PipelineTemplateInfo {
pub id: String,
pub display_name: String,
pub description: String,
pub category: String,
pub industry: String,
pub tags: Vec<String>,
pub icon: String,
pub version: String,
pub author: String,
pub inputs: Vec<PipelineInputInfo>,
}
/// List available pipeline templates from the `_templates/` directory.
///
/// Templates are pipeline YAML files that users can browse and instantiate.
/// They live in `pipelines/_templates/` and are not directly runnable
/// (they serve as blueprints).
#[tauri::command]
pub async fn pipeline_templates(
state: State<'_, Arc<PipelineState>>,
) -> Result<Vec<PipelineTemplateInfo>, String> {
let pipelines = state.pipelines.read().await;
// Filter pipelines that have `is_template: true` in metadata
// or are in the _templates directory
let templates: Vec<PipelineTemplateInfo> = pipelines.iter()
.filter_map(|(id, pipeline)| {
// Check if this pipeline has template metadata
let is_template = pipeline.metadata.annotations
.as_ref()
.and_then(|a| a.get("is_template"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_template {
return None;
}
Some(PipelineTemplateInfo {
id: pipeline.metadata.name.clone(),
display_name: pipeline.metadata.display_name.clone()
.unwrap_or_else(|| pipeline.metadata.name.clone()),
description: pipeline.metadata.description.clone().unwrap_or_default(),
category: pipeline.metadata.category.clone().unwrap_or_default(),
industry: pipeline.metadata.industry.clone().unwrap_or_default(),
tags: pipeline.metadata.tags.clone(),
icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()),
version: pipeline.metadata.version.clone(),
author: pipeline.metadata.author.clone().unwrap_or_default(),
inputs: pipeline.spec.inputs.iter().map(|input| {
PipelineInputInfo {
name: input.name.clone(),
input_type: match input.input_type {
zclaw_pipeline::InputType::String => "string".to_string(),
zclaw_pipeline::InputType::Number => "number".to_string(),
zclaw_pipeline::InputType::Boolean => "boolean".to_string(),
zclaw_pipeline::InputType::Select => "select".to_string(),
zclaw_pipeline::InputType::MultiSelect => "multi-select".to_string(),
zclaw_pipeline::InputType::File => "file".to_string(),
zclaw_pipeline::InputType::Text => "text".to_string(),
},
required: input.required,
label: input.label.clone().unwrap_or_else(|| input.name.clone()),
placeholder: input.placeholder.clone(),
default: input.default.clone(),
options: input.options.clone(),
}
}).collect(),
})
})
.collect();
tracing::debug!("[pipeline_templates] Found {} templates", templates.len());
Ok(templates)
}