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

@@ -1,21 +0,0 @@
[package]
name = "zclaw-channels"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "ZCLAW Channels - external platform adapters"
[dependencies]
zclaw-types = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,71 +0,0 @@
//! Console channel adapter for testing
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::mpsc;
use zclaw_types::Result;
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
/// Console channel adapter (for testing)
pub struct ConsoleChannel {
config: ChannelConfig,
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
}
impl ConsoleChannel {
pub fn new(config: ChannelConfig) -> Self {
Self {
config,
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
}
}
}
#[async_trait]
impl Channel for ConsoleChannel {
fn config(&self) -> &ChannelConfig {
&self.config
}
async fn connect(&self) -> Result<()> {
let mut status = self.status.write().await;
*status = ChannelStatus::Connected;
tracing::info!("Console channel connected");
Ok(())
}
async fn disconnect(&self) -> Result<()> {
let mut status = self.status.write().await;
*status = ChannelStatus::Disconnected;
tracing::info!("Console channel disconnected");
Ok(())
}
async fn status(&self) -> ChannelStatus {
self.status.read().await.clone()
}
async fn send(&self, message: OutgoingMessage) -> Result<String> {
// Print to console for testing
let msg_id = format!("console_{}", chrono::Utc::now().timestamp());
match &message.content {
crate::MessageContent::Text { text } => {
tracing::info!("[Console] To {}: {}", message.conversation_id, text);
}
_ => {
tracing::info!("[Console] To {}: {:?}", message.conversation_id, message.content);
}
}
Ok(msg_id)
}
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
let (_tx, rx) = mpsc::channel(100);
// Console channel doesn't receive messages automatically
// Messages would need to be injected via a separate method
Ok(rx)
}
}

View File

@@ -1,5 +0,0 @@
//! Channel adapters
mod console;
pub use console::ConsoleChannel;

View File

@@ -1,94 +0,0 @@
//! Channel bridge manager
//!
//! Coordinates multiple channel adapters and routes messages.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use zclaw_types::Result;
use super::{Channel, ChannelConfig, OutgoingMessage};
/// Channel bridge manager
pub struct ChannelBridge {
channels: RwLock<HashMap<String, Arc<dyn Channel>>>,
configs: RwLock<HashMap<String, ChannelConfig>>,
}
impl ChannelBridge {
pub fn new() -> Self {
Self {
channels: RwLock::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
}
}
/// Register a channel adapter
pub async fn register(&self, channel: Arc<dyn Channel>) {
let config = channel.config().clone();
let mut channels = self.channels.write().await;
let mut configs = self.configs.write().await;
channels.insert(config.id.clone(), channel);
configs.insert(config.id.clone(), config);
}
/// Get a channel by ID
pub async fn get(&self, id: &str) -> Option<Arc<dyn Channel>> {
let channels = self.channels.read().await;
channels.get(id).cloned()
}
/// Get channel configuration
pub async fn get_config(&self, id: &str) -> Option<ChannelConfig> {
let configs = self.configs.read().await;
configs.get(id).cloned()
}
/// List all channels
pub async fn list(&self) -> Vec<ChannelConfig> {
let configs = self.configs.read().await;
configs.values().cloned().collect()
}
/// Connect all channels
pub async fn connect_all(&self) -> Result<()> {
let channels = self.channels.read().await;
for channel in channels.values() {
channel.connect().await?;
}
Ok(())
}
/// Disconnect all channels
pub async fn disconnect_all(&self) -> Result<()> {
let channels = self.channels.read().await;
for channel in channels.values() {
channel.disconnect().await?;
}
Ok(())
}
/// Send message through a specific channel
pub async fn send(&self, channel_id: &str, message: OutgoingMessage) -> Result<String> {
let channel = self.get(channel_id).await
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Channel not found: {}", channel_id)))?;
channel.send(message).await
}
/// Remove a channel
pub async fn remove(&self, id: &str) {
let mut channels = self.channels.write().await;
let mut configs = self.configs.write().await;
channels.remove(id);
configs.remove(id);
}
}
impl Default for ChannelBridge {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,109 +0,0 @@
//! Channel trait and types
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use zclaw_types::{Result, AgentId};
/// Channel configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
/// Unique channel identifier
pub id: String,
/// Channel type (telegram, discord, slack, etc.)
pub channel_type: String,
/// Human-readable name
pub name: String,
/// Whether the channel is enabled
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Channel-specific configuration
#[serde(default)]
pub config: serde_json::Value,
/// Associated agent for this channel
pub agent_id: Option<AgentId>,
}
fn default_enabled() -> bool { true }
/// Incoming message from a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingMessage {
/// Message ID from the platform
pub platform_id: String,
/// Channel/conversation ID
pub conversation_id: String,
/// Sender information
pub sender: MessageSender,
/// Message content
pub content: MessageContent,
/// Timestamp
pub timestamp: i64,
/// Reply-to message ID if any
pub reply_to: Option<String>,
}
/// Message sender information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageSender {
pub id: String,
pub name: Option<String>,
pub username: Option<String>,
pub is_bot: bool,
}
/// Message content types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageContent {
Text { text: String },
Image { url: String, caption: Option<String> },
File { url: String, filename: String },
Audio { url: String },
Video { url: String },
Location { latitude: f64, longitude: f64 },
Sticker { emoji: Option<String>, url: Option<String> },
}
/// Outgoing message to a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingMessage {
/// Conversation/channel ID to send to
pub conversation_id: String,
/// Message content
pub content: MessageContent,
/// Reply-to message ID if any
pub reply_to: Option<String>,
/// Whether to send silently (no notification)
pub silent: bool,
}
/// Channel connection status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ChannelStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
/// Channel trait for platform adapters
#[async_trait]
pub trait Channel: Send + Sync {
/// Get channel configuration
fn config(&self) -> &ChannelConfig;
/// Connect to the platform
async fn connect(&self) -> Result<()>;
/// Disconnect from the platform
async fn disconnect(&self) -> Result<()>;
/// Get current connection status
async fn status(&self) -> ChannelStatus;
/// Send a message
async fn send(&self, message: OutgoingMessage) -> Result<String>;
/// Receive incoming messages (streaming)
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
}

View File

@@ -1,11 +0,0 @@
//! ZCLAW Channels
//!
//! External platform adapters for unified message handling.
mod channel;
mod bridge;
mod adapters;
pub use channel::*;
pub use bridge::*;
pub use adapters::*;

View File

@@ -20,3 +20,6 @@ thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
hmac = "0.12"
sha1 = "0.10"
base64 = { workspace = true }

View File

@@ -233,17 +233,32 @@ impl SpeechHand {
state.playback = PlaybackState::Playing;
state.current_text = Some(text.clone());
// In real implementation, would call TTS API
// Determine TTS method based on provider:
// - Browser: frontend uses Web Speech API (zero deps, works offline)
// - OpenAI: frontend calls speech_tts command (high-quality, needs API key)
// - Others: future support
let tts_method = match state.config.provider {
TtsProvider::Browser => "browser",
TtsProvider::OpenAI => "openai_api",
TtsProvider::Azure => "azure_api",
TtsProvider::ElevenLabs => "elevenlabs_api",
TtsProvider::Local => "local_engine",
};
let estimated_duration_ms = (text.chars().count() as f64 / 5.0 * 1000.0) as u64;
Ok(HandResult::success(serde_json::json!({
"status": "speaking",
"tts_method": tts_method,
"text": text,
"voice": voice_id,
"language": lang,
"rate": actual_rate,
"pitch": actual_pitch,
"volume": actual_volume,
"provider": state.config.provider,
"duration_ms": text.len() as u64 * 80, // Rough estimate
"provider": format!("{:?}", state.config.provider).to_lowercase(),
"duration_ms": estimated_duration_ms,
"instruction": "Frontend should play this via TTS engine"
})))
}
SpeechAction::SpeakSsml { ssml, voice } => {

View File

@@ -289,117 +289,435 @@ impl TwitterHand {
c.clone()
}
/// Execute tweet action
/// Execute tweet action — POST /2/tweets
async fn execute_tweet(&self, config: &TweetConfig) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
// Simulated tweet response (actual implementation would use Twitter API)
// In production, this would call Twitter API v2: POST /2/tweets
let client = reqwest::Client::new();
let body = json!({ "text": config.text });
let response = client.post("https://api.twitter.com/2/tweets")
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&body)
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Twitter API request failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
tracing::warn!("[TwitterHand] Tweet failed: {} - {}", status, response_text);
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
// Parse the response to extract tweet_id
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"tweet_id": format!("simulated_{}", chrono::Utc::now().timestamp()),
"tweet_id": parsed["data"]["id"].as_str().unwrap_or("unknown"),
"text": config.text,
"created_at": chrono::Utc::now().to_rfc3339(),
"message": "Tweet posted successfully (simulated)",
"note": "Connect Twitter API credentials for actual posting"
"raw_response": parsed,
"message": "Tweet posted successfully"
}))
}
/// Execute search action
/// Execute search action — GET /2/tweets/search/recent
async fn execute_search(&self, config: &SearchConfig) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
// Simulated search response
// In production, this would call Twitter API v2: GET /2/tweets/search/recent
let client = reqwest::Client::new();
let max = config.max_results.max(10).min(100);
let response = client.get("https://api.twitter.com/2/tweets/search/recent")
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("query", config.query.as_str()),
("max_results", max.to_string().as_str()),
("tweet.fields", "created_at,author_id,public_metrics,lang"),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Twitter search failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"query": config.query,
"tweets": [],
"meta": {
"result_count": 0,
"newest_id": null,
"oldest_id": null,
"next_token": null
},
"message": "Search completed (simulated - no actual results without API)",
"note": "Connect Twitter API credentials for actual search results"
"tweets": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": parsed["meta"].clone(),
"message": "Search completed"
}))
}
/// Execute timeline action
/// Execute timeline action — GET /2/users/:id/timelines/reverse_chronological
async fn execute_timeline(&self, config: &TimelineConfig) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
// Simulated timeline response
let client = reqwest::Client::new();
let user_id = config.user_id.as_deref().unwrap_or("me");
let url = format!("https://api.twitter.com/2/users/{}/timelines/reverse_chronological", user_id);
let max = config.max_results.max(5).min(100);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("max_results", max.to_string().as_str()),
("tweet.fields", "created_at,author_id,public_metrics"),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Timeline fetch failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"user_id": config.user_id,
"tweets": [],
"meta": {
"result_count": 0,
"newest_id": null,
"oldest_id": null,
"next_token": null
},
"message": "Timeline fetched (simulated)",
"note": "Connect Twitter API credentials for actual timeline"
"user_id": user_id,
"tweets": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": parsed["meta"].clone(),
"message": "Timeline fetched"
}))
}
/// Get tweet by ID
/// Get tweet by ID — GET /2/tweets/:id
async fn execute_get_tweet(&self, tweet_id: &str) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[("tweet.fields", "created_at,author_id,public_metrics,lang")])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Tweet lookup failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"tweet_id": tweet_id,
"tweet": null,
"message": "Tweet lookup (simulated)",
"note": "Connect Twitter API credentials for actual tweet data"
"tweet": parsed["data"].clone(),
"message": "Tweet fetched"
}))
}
/// Get user by username
/// Get user by username — GET /2/users/by/username/:username
async fn execute_get_user(&self, username: &str) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/by/username/{}", username);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[("user.fields", "created_at,description,public_metrics,verified")])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("User lookup failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"username": username,
"user": null,
"message": "User lookup (simulated)",
"note": "Connect Twitter API credentials for actual user data"
"user": parsed["data"].clone(),
"message": "User fetched"
}))
}
/// Execute like action
/// Execute like action — PUT /2/users/:id/likes
async fn execute_like(&self, tweet_id: &str) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
// Note: For like/retweet, we need OAuth 1.0a user context
// Using Bearer token as fallback (may not work for all endpoints)
let url = "https://api.twitter.com/2/users/me/likes";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Like failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": true,
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "liked",
"message": "Tweet liked (simulated)"
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet liked" } else { &response_text }
}))
}
/// Execute retweet action
/// Execute retweet action — POST /2/users/:id/retweets
async fn execute_retweet(&self, tweet_id: &str) -> Result<Value> {
let _creds = self.get_credentials().await
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = "https://api.twitter.com/2/users/me/retweets";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Retweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "retweeted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet retweeted" } else { &response_text }
}))
}
/// Execute delete tweet — DELETE /2/tweets/:id
async fn execute_delete_tweet(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/tweets/{}", tweet_id);
let response = client.delete(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Delete tweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "deleted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet deleted" } else { &response_text }
}))
}
/// Execute unretweet — DELETE /2/users/:id/retweets/:tweet_id
async fn execute_unretweet(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/me/retweets/{}", tweet_id);
let response = client.delete(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Unretweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "unretweeted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet unretweeted" } else { &response_text }
}))
}
/// Execute unlike — DELETE /2/users/:id/likes/:tweet_id
async fn execute_unlike(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/me/likes/{}", tweet_id);
let response = client.delete(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Unlike failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "unliked",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet unliked" } else { &response_text }
}))
}
/// Execute followers fetch — GET /2/users/:id/followers
async fn execute_followers(&self, user_id: &str, max_results: Option<u32>) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/{}/followers", user_id);
let max = max_results.unwrap_or(100).max(1).min(1000);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("max_results", max.to_string()),
("user.fields", "created_at,description,public_metrics,verified,profile_image_url".to_string()),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Followers fetch failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"tweet_id": tweet_id,
"action": "retweeted",
"message": "Tweet retweeted (simulated)"
"user_id": user_id,
"followers": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": parsed["meta"].clone(),
"message": "Followers fetched"
}))
}
/// Execute following fetch — GET /2/users/:id/following
async fn execute_following(&self, user_id: &str, max_results: Option<u32>) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = format!("https://api.twitter.com/2/users/{}/following", user_id);
let max = max_results.unwrap_or(100).max(1).min(1000);
let response = client.get(&url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("User-Agent", "ZCLAW/1.0")
.query(&[
("max_results", max.to_string()),
("user.fields", "created_at,description,public_metrics,verified,profile_image_url".to_string()),
])
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Following fetch failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
return Ok(json!({
"success": false,
"error": format!("Twitter API returned {}: {}", status, response_text),
"status_code": status.as_u16()
}));
}
let parsed: Value = serde_json::from_str(&response_text).unwrap_or(json!({"raw": response_text}));
Ok(json!({
"success": true,
"user_id": user_id,
"following": parsed["data"].as_array().cloned().unwrap_or_default(),
"meta": parsed["meta"].clone(),
"message": "Following fetched"
}))
}
@@ -461,54 +779,17 @@ impl Hand for TwitterHand {
let result = match action {
TwitterAction::Tweet { config } => self.execute_tweet(&config).await?,
TwitterAction::DeleteTweet { tweet_id } => {
json!({
"success": true,
"tweet_id": tweet_id,
"action": "deleted",
"message": "Tweet deleted (simulated)"
})
}
TwitterAction::DeleteTweet { tweet_id } => self.execute_delete_tweet(&tweet_id).await?,
TwitterAction::Retweet { tweet_id } => self.execute_retweet(&tweet_id).await?,
TwitterAction::Unretweet { tweet_id } => {
json!({
"success": true,
"tweet_id": tweet_id,
"action": "unretweeted",
"message": "Tweet unretweeted (simulated)"
})
}
TwitterAction::Unretweet { tweet_id } => self.execute_unretweet(&tweet_id).await?,
TwitterAction::Like { tweet_id } => self.execute_like(&tweet_id).await?,
TwitterAction::Unlike { tweet_id } => {
json!({
"success": true,
"tweet_id": tweet_id,
"action": "unliked",
"message": "Tweet unliked (simulated)"
})
}
TwitterAction::Unlike { tweet_id } => self.execute_unlike(&tweet_id).await?,
TwitterAction::Search { config } => self.execute_search(&config).await?,
TwitterAction::Timeline { config } => self.execute_timeline(&config).await?,
TwitterAction::GetTweet { tweet_id } => self.execute_get_tweet(&tweet_id).await?,
TwitterAction::GetUser { username } => self.execute_get_user(&username).await?,
TwitterAction::Followers { user_id, max_results } => {
json!({
"success": true,
"user_id": user_id,
"followers": [],
"max_results": max_results.unwrap_or(100),
"message": "Followers fetched (simulated)"
})
}
TwitterAction::Following { user_id, max_results } => {
json!({
"success": true,
"user_id": user_id,
"following": [],
"max_results": max_results.unwrap_or(100),
"message": "Following fetched (simulated)"
})
}
TwitterAction::Followers { user_id, max_results } => self.execute_followers(&user_id, max_results).await?,
TwitterAction::Following { user_id, max_results } => self.execute_following(&user_id, max_results).await?,
TwitterAction::CheckCredentials => self.execute_check_credentials().await?,
};

View File

@@ -132,6 +132,8 @@ pub struct Kernel {
running_hand_runs: Arc<dashmap::DashMap<HandRunId, Arc<std::sync::atomic::AtomicBool>>>,
/// Shared memory storage backend for Growth system
viking: Arc<zclaw_runtime::VikingAdapter>,
/// Optional LLM driver for memory extraction (set by Tauri desktop layer)
extraction_driver: Option<Arc<dyn zclaw_runtime::LlmDriverForExtraction>>,
/// A2A router for inter-agent messaging (gated by multi-agent feature)
#[cfg(feature = "multi-agent")]
a2a_router: Arc<A2aRouter>,
@@ -223,6 +225,7 @@ impl Kernel {
pending_approvals: Arc::new(Mutex::new(Vec::new())),
running_hand_runs: Arc::new(dashmap::DashMap::new()),
viking,
extraction_driver: None,
#[cfg(feature = "multi-agent")]
a2a_router,
#[cfg(feature = "multi-agent")]
@@ -246,13 +249,19 @@ impl Kernel {
let mut chain = zclaw_runtime::middleware::MiddlewareChain::new();
// Growth integration — shared VikingAdapter for memory middleware & compaction
let growth = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
let mut growth = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
if let Some(ref driver) = self.extraction_driver {
growth = growth.with_llm_driver(driver.clone());
}
// Compaction middleware — only register when threshold > 0
let threshold = self.config.compaction_threshold();
if threshold > 0 {
use std::sync::Arc;
let growth_for_compaction = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
let mut growth_for_compaction = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
if let Some(ref driver) = self.extraction_driver {
growth_for_compaction = growth_for_compaction.with_llm_driver(driver.clone());
}
let mw = zclaw_runtime::middleware::compaction::CompactionMiddleware::new(
threshold,
zclaw_runtime::CompactionConfig::default(),
@@ -657,6 +666,30 @@ impl Kernel {
self.driver.clone()
}
/// Replace the default in-memory VikingAdapter with a persistent one.
///
/// Called by the Tauri desktop layer after `Kernel::boot()` to bridge
/// the kernel's Growth system to the same SqliteStorage used by
/// viking_commands and intelligence_hooks.
pub fn set_viking(&mut self, viking: Arc<zclaw_runtime::VikingAdapter>) {
tracing::info!("[Kernel] Replacing in-memory VikingAdapter with persistent storage");
self.viking = viking;
}
/// Get a reference to the shared VikingAdapter
pub fn viking(&self) -> Arc<zclaw_runtime::VikingAdapter> {
self.viking.clone()
}
/// Set the LLM extraction driver for the Growth system.
///
/// Required for `MemoryMiddleware` to extract memories from conversations
/// via LLM analysis. If not set, memory extraction is silently skipped.
pub fn set_extraction_driver(&mut self, driver: Arc<dyn zclaw_runtime::LlmDriverForExtraction>) {
tracing::info!("[Kernel] Extraction driver configured for Growth system");
self.extraction_driver = Some(driver);
}
/// Get the skills registry
pub fn skills(&self) -> &Arc<SkillRegistry> {
&self.skills

View File

@@ -61,6 +61,10 @@ pub struct PipelineMetadata {
/// Version string
#[serde(default = "default_version")]
pub version: String,
/// Arbitrary key-value annotations (e.g., is_template: true)
#[serde(default)]
pub annotations: Option<std::collections::HashMap<String, serde_json::Value>>,
}
fn default_version() -> String {

View File

@@ -4,14 +4,11 @@
//! enabling automatic memory retrieval before conversations and memory extraction
//! after conversations.
//!
//! **Note (2026-03-27 audit)**: In the Tauri desktop deployment, this module is
//! NOT wired into the Kernel. The intelligence_hooks module in desktop/src-tauri
//! provides the same functionality (memory retrieval, heartbeat, reflection) via
//! direct VikingStorage calls. GrowthIntegration remains available for future
//! use (e.g., headless/server deployments where intelligence_hooks is not available).
//!
//! The `AgentLoop.growth` field defaults to `None` and the code gracefully falls
//! through to normal behavior when not set.
//! **Note (2026-03-30)**: GrowthIntegration IS wired into the Kernel's middleware
//! chain (MemoryMiddleware + CompactionMiddleware). In the Tauri desktop deployment,
//! `kernel_commands::kernel_init()` bridges the persistent SqliteStorage to the Kernel
//! via `set_viking()` + `set_extraction_driver()`, so the middleware chain and the
//! Tauri intelligence_hooks share the same persistent storage backend.
use std::sync::Arc;
use zclaw_growth::{

View File

@@ -29,4 +29,5 @@ pub use stream::{StreamEvent, StreamSender};
pub use growth::GrowthIntegration;
pub use zclaw_growth::VikingAdapter;
pub use zclaw_growth::EmbeddingClient;
pub use zclaw_growth::LlmDriverForExtraction;
pub use compaction::{CompactionConfig, CompactionOutcome};

View File

@@ -66,10 +66,14 @@ async fn main() -> anyhow::Result<()> {
}
async fn health_handler(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
let db_healthy = sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.db)
.await
.is_ok();
// health 必须独立快速返回,用 3s 超时避免连接池满时阻塞
let db_healthy = tokio::time::timeout(
std::time::Duration::from_secs(3),
sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&state.db),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
let status = if db_healthy { "healthy" } else { "degraded" };
let _code = if db_healthy { 200 } else { 503 };

View File

@@ -441,9 +441,9 @@ pub async fn get_usage_stats(
.and_hms_opt(0, 0, 0).unwrap()
.and_utc()
.to_rfc3339();
let daily_sql = "SELECT SUBSTRING(created_at, 1, 10) as day, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens
let daily_sql = "SELECT created_at::date::text as day, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM usage_records WHERE account_id = $1 AND created_at >= $2
GROUP BY SUBSTRING(created_at, 1, 10) ORDER BY day DESC LIMIT $3";
GROUP BY created_at::date ORDER BY day DESC LIMIT $3";
let daily_rows: Vec<UsageByDayRow> = sqlx::query_as(daily_sql)
.bind(account_id).bind(&from_days).bind(days as i32)
.fetch_all(db).await?;

View File

@@ -142,6 +142,13 @@ pub async fn chat_completions(
let target_model = target_model
.ok_or_else(|| SaasError::NotFound(format!("模型 {} 不存在或未启用", model_name)))?;
// Stream compatibility check: reject stream requests for non-streaming models
if stream && !target_model.supports_streaming {
return Err(SaasError::InvalidInput(
format!("模型 {} 不支持流式响应,请使用 stream: false", model_name)
));
}
// 获取 provider 信息
let provider = model_service::get_provider(&state.db, &target_model.provider_id).await?;
if !provider.enabled {
@@ -385,6 +392,12 @@ pub async fn add_provider_key(
if req.key_value.trim().is_empty() {
return Err(SaasError::InvalidInput("key_value 不能为空".into()));
}
if req.key_value.len() < 20 {
return Err(SaasError::InvalidInput("key_value 长度不足(至少 20 字符)".into()));
}
if req.key_value.contains(char::is_whitespace) {
return Err(SaasError::InvalidInput("key_value 不能包含空白字符".into()));
}
let key_id = super::key_pool::add_provider_key(
&state.db, &provider_id, &req.key_label, &req.key_value,

View File

@@ -240,7 +240,7 @@ pub async fn get_daily_stats(
.to_rfc3339();
let sql = "SELECT
SUBSTRING(reported_at, 1, 10) as day,
reported_at::date::text as day,
COUNT(*)::bigint as request_count,
COALESCE(SUM(input_tokens), 0)::bigint as input_tokens,
COALESCE(SUM(output_tokens), 0)::bigint as output_tokens,
@@ -248,7 +248,7 @@ pub async fn get_daily_stats(
FROM telemetry_reports
WHERE account_id = $1
AND reported_at >= $2
GROUP BY SUBSTRING(reported_at, 1, 10)
GROUP BY reported_at::date
ORDER BY day DESC";
let rows: Vec<TelemetryDailyStatsRow> =