feat(auth): 添加异步密码哈希和验证函数
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

refactor(relay): 复用HTTP客户端和请求体序列化结果

feat(kernel): 添加获取单个审批记录的方法

fix(store): 改进SaaS连接错误分类和降级处理

docs: 更新审计文档和系统架构文档

refactor(prompt): 优化SQL查询参数化绑定

refactor(migration): 使用静态SQL和COALESCE更新配置项

feat(commands): 添加审批执行状态追踪和事件通知

chore: 更新启动脚本以支持Admin后台

fix(auth-guard): 优化授权状态管理和错误处理

refactor(db): 使用异步密码哈希函数

refactor(totp): 使用异步密码验证函数

style: 清理无用文件和注释

docs: 更新功能全景和审计文档

refactor(service): 优化HTTP客户端重用和请求处理

fix(connection): 改进SaaS不可用时的降级处理

refactor(handlers): 使用异步密码验证函数

chore: 更新依赖和工具链配置
This commit is contained in:
iven
2026-03-29 21:45:29 +08:00
parent b7ec317d2c
commit 7de294375b
34 changed files with 2041 additions and 894 deletions

View File

@@ -0,0 +1,321 @@
//! Extraction Adapter - Bridges zclaw_growth::LlmDriverForExtraction with the Kernel's LlmDriver
//!
//! Implements the `LlmDriverForExtraction` trait by delegating to the Kernel's
//! `zclaw_runtime::driver::LlmDriver`, which already handles provider-specific
//! API calls (OpenAI, Anthropic, Gemini, etc.).
//!
//! This enables the Growth system's MemoryExtractor to call the LLM for memory
//! extraction from conversations.
use std::sync::Arc;
use zclaw_growth::extractor::{LlmDriverForExtraction, prompts};
use zclaw_growth::types::{ExtractedMemory, MemoryType};
use zclaw_runtime::driver::{CompletionRequest, ContentBlock, LlmDriver};
use zclaw_types::{Message, Result, SessionId};
/// Adapter that wraps the Kernel's `LlmDriver` to implement `LlmDriverForExtraction`.
///
/// The adapter translates extraction requests into completion requests that the
/// Kernel's LLM driver can process, then parses the structured JSON response
/// back into `ExtractedMemory` objects.
pub struct TauriExtractionDriver {
driver: Arc<dyn LlmDriver>,
model: String,
}
impl TauriExtractionDriver {
/// Create a new extraction driver wrapping the given LLM driver.
///
/// The `model` parameter specifies which model to use for extraction calls.
pub fn new(driver: Arc<dyn LlmDriver>, model: String) -> Self {
Self { driver, model }
}
/// Build a completion request from the extraction prompt and conversation messages.
fn build_request(
&self,
messages: &[Message],
extraction_type: MemoryType,
) -> CompletionRequest {
let extraction_prompt = prompts::get_extraction_prompt(extraction_type);
// Format conversation for the prompt
// Message is an enum with variants: User{content}, Assistant{content, thinking},
// System{content}, ToolUse{...}, ToolResult{...}
let conversation_text = messages
.iter()
.filter_map(|msg| {
match msg {
Message::User { content } => {
Some(format!("[User]: {}", content))
}
Message::Assistant { content, .. } => {
Some(format!("[Assistant]: {}", content))
}
Message::System { content } => {
Some(format!("[System]: {}", content))
}
// Skip tool use/result messages -- not relevant for memory extraction
Message::ToolUse { .. } | Message::ToolResult { .. } => None,
}
})
.collect::<Vec<_>>()
.join("\n\n");
let full_prompt = format!("{}{}", extraction_prompt, conversation_text);
CompletionRequest {
model: self.model.clone(),
system: Some(
"You are a memory extraction assistant. Analyze conversations and extract \
structured memories in valid JSON format. Always respond with valid JSON only, \
no additional text or markdown formatting."
.to_string(),
),
messages: vec![Message::user(full_prompt)],
tools: Vec::new(),
max_tokens: Some(2000),
temperature: Some(0.3),
stop: Vec::new(),
stream: false,
}
}
/// Parse the LLM response text into a list of extracted memories.
fn parse_response(
&self,
response_text: &str,
extraction_type: MemoryType,
) -> Vec<ExtractedMemory> {
// Strip markdown code fences if present
let cleaned = response_text
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
// Extract the JSON array from the response
let json_str = match (cleaned.find('['), cleaned.rfind(']')) {
(Some(start), Some(end)) => &cleaned[start..=end],
_ => {
tracing::warn!(
"[TauriExtractionDriver] No JSON array found in LLM response"
);
return Vec::new();
}
};
let raw_items: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(items) => items,
Err(e) => {
tracing::warn!(
"[TauriExtractionDriver] Failed to parse extraction JSON: {}",
e
);
return Vec::new();
}
};
raw_items
.into_iter()
.filter_map(|item| self.parse_memory_item(&item, extraction_type))
.collect()
}
/// Parse a single memory item from JSON.
fn parse_memory_item(
&self,
value: &serde_json::Value,
fallback_type: MemoryType,
) -> Option<ExtractedMemory> {
let content = value.get("content")?.as_str()?.to_string();
let category = value
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let confidence = value
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.7) as f32;
let keywords = value
.get("keywords")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Some(
ExtractedMemory::new(fallback_type, category, content, SessionId::new())
.with_confidence(confidence)
.with_keywords(keywords),
)
}
}
#[async_trait::async_trait]
impl LlmDriverForExtraction for TauriExtractionDriver {
async fn extract_memories(
&self,
messages: &[Message],
extraction_type: MemoryType,
) -> Result<Vec<ExtractedMemory>> {
let type_name = format!("{}", extraction_type);
tracing::debug!(
"[TauriExtractionDriver] Extracting {} memories from {} messages",
type_name,
messages.len()
);
// Skip extraction if there are too few messages
if messages.len() < 2 {
tracing::debug!(
"[TauriExtractionDriver] Too few messages ({}) for extraction, skipping",
messages.len()
);
return Ok(Vec::new());
}
let request = self.build_request(messages, extraction_type);
let response = self.driver.complete(request).await.map_err(|e| {
tracing::error!(
"[TauriExtractionDriver] LLM completion failed for {}: {}",
type_name,
e
);
e
})?;
// Extract text content from response
let response_text: String = response
.content
.into_iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join("");
if response_text.is_empty() {
tracing::warn!(
"[TauriExtractionDriver] Empty response from LLM for {} extraction",
type_name
);
return Ok(Vec::new());
}
let memories = self.parse_response(&response_text, extraction_type);
tracing::info!(
"[TauriExtractionDriver] Extracted {} {} memories",
memories.len(),
type_name
);
Ok(memories)
}
}
/// Global extraction driver instance (lazy-initialized).
static EXTRACTION_DRIVER: tokio::sync::OnceCell<Arc<TauriExtractionDriver>> =
tokio::sync::OnceCell::const_new();
/// Configure the global extraction driver.
///
/// Call this during kernel initialization after the Kernel's LLM driver is available.
pub fn configure_extraction_driver(driver: Arc<dyn LlmDriver>, model: String) {
let adapter = TauriExtractionDriver::new(driver, model);
let _ = EXTRACTION_DRIVER.set(Arc::new(adapter));
tracing::info!("[ExtractionAdapter] Extraction driver configured");
}
/// Check if the extraction driver is available.
#[allow(dead_code)]
pub fn is_extraction_driver_configured() -> bool {
EXTRACTION_DRIVER.get().is_some()
}
/// Get the global extraction driver.
///
/// Returns `None` if not yet configured via `configure_extraction_driver`.
pub fn get_extraction_driver() -> Option<Arc<TauriExtractionDriver>> {
EXTRACTION_DRIVER.get().cloned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extraction_driver_not_configured_by_default() {
assert!(!is_extraction_driver_configured());
}
#[test]
fn test_parse_empty_response() {
// We cannot create a real LlmDriver easily in tests, so we test the
// parsing logic via a minimal helper.
struct DummyDriver;
impl TauriExtractionDriver {
fn parse_response_test(
&self,
response_text: &str,
extraction_type: MemoryType,
) -> Vec<ExtractedMemory> {
self.parse_response(response_text, extraction_type)
}
}
}
#[test]
fn test_parse_valid_json_response() {
let response = r#"```json
[
{
"category": "communication-style",
"content": "User prefers concise replies",
"confidence": 0.9,
"keywords": ["concise", "style"]
},
{
"category": "language",
"content": "User prefers Chinese responses",
"confidence": 0.85,
"keywords": ["Chinese", "language"]
}
]
```"#;
// Verify the parsing logic works by manually simulating it
let cleaned = response
.trim()
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let json_str = &cleaned[cleaned.find('[').unwrap()..=cleaned.rfind(']').unwrap()];
let items: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(
items[0].get("category").unwrap().as_str().unwrap(),
"communication-style"
);
}
#[test]
fn test_parse_no_json_array() {
let response = "No memories could be extracted from this conversation.";
let has_array =
response.find('[').is_some() && response.rfind(']').is_some();
assert!(!has_array);
}
}

View File

@@ -31,6 +31,7 @@ pub mod compactor;
pub mod reflection;
pub mod identity;
pub mod validation;
pub mod extraction_adapter;
// Re-export main types for convenience
pub use heartbeat::HeartbeatEngineState;
@@ -40,3 +41,13 @@ pub use reflection::{
pub use identity::{
AgentIdentityManager, IdentityManagerState,
};
// Suppress dead-code warnings for extraction adapter accessors — they are
// consumed externally via full path (crate::intelligence::extraction_adapter::*).
#[allow(unused_imports)]
use extraction_adapter::{
configure_extraction_driver as _,
is_extraction_driver_configured as _,
get_extraction_driver as _,
TauriExtractionDriver as _,
};

View File

@@ -215,6 +215,24 @@ pub async fn kernel_init(
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(),
);
// 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);
Ok(KernelStatusResponse {
@@ -1251,24 +1269,109 @@ pub async fn approval_list(
}
/// Respond to an approval
///
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
/// execution. We additionally emit Tauri events so the frontend can track when
/// the execution finishes, since the kernel layer has no access to the AppHandle.
#[tauri::command]
pub async fn approval_respond(
app: AppHandle,
state: State<'_, KernelState>,
id: String,
approved: bool,
reason: Option<String>,
) -> Result<(), String> {
let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref()
.ok_or_else(|| "Kernel not initialized".to_string())?;
// Capture hand info before calling respond_to_approval (which mutates the approval)
let hand_id = {
let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref()
.ok_or_else(|| "Kernel not initialized".to_string())?;
kernel.respond_to_approval(&id, approved, reason).await
.map_err(|e| format!("Failed to respond to approval: {}", e))
let approvals = kernel.list_approvals().await;
let entry = approvals.iter().find(|a| a.id == id && a.status == "pending")
.ok_or_else(|| format!("Approval not found or already resolved: {}", id))?;
entry.hand_id.clone()
};
// Call kernel respond_to_approval (this updates status and spawns Hand execution)
{
let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref()
.ok_or_else(|| "Kernel not initialized".to_string())?;
kernel.respond_to_approval(&id, approved, reason).await
.map_err(|e| format!("Failed to respond to approval: {}", e))?;
}
// When approved, monitor the Hand execution and emit events to the frontend.
// The kernel's respond_to_approval changes status to "approved" immediately,
// then the spawned task sets it to "completed" or "failed" when done.
if approved {
let approval_id = id.clone();
let kernel_state: KernelState = (*state).clone();
tokio::spawn(async move {
let timeout = tokio::time::Duration::from_secs(300);
let poll_interval = tokio::time::Duration::from_millis(500);
let result = tokio::time::timeout(timeout, async {
loop {
tokio::time::sleep(poll_interval).await;
let kernel_lock = kernel_state.lock().await;
if let Some(kernel) = kernel_lock.as_ref() {
// Use get_approval to check any status (not just "pending")
if let Some(entry) = kernel.get_approval(&approval_id).await {
match entry.status.as_str() {
"completed" => {
tracing::info!("[approval_respond] Hand '{}' completed for approval {}", hand_id, approval_id);
return (true, None::<String>);
}
"failed" => {
let error_msg = entry.input.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error")
.to_string();
tracing::warn!("[approval_respond] Hand '{}' failed for approval {}: {}", hand_id, approval_id, error_msg);
return (false, Some(error_msg));
}
_ => {} // "approved" = still running
}
} else {
// Entry disappeared entirely — kernel was likely restarted
return (false, Some("Approval entry disappeared".to_string()));
}
} else {
return (false, Some("Kernel not available".to_string()));
}
}
}).await;
let (success, error) = match result {
Ok((s, e)) => (s, e),
Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())),
};
let _ = app.emit("hand-execution-complete", serde_json::json!({
"approvalId": approval_id,
"handId": hand_id,
"success": success,
"error": error,
}));
});
}
Ok(())
}
/// Approve a hand execution (alias for approval_respond with approved=true)
/// Approve a hand execution
///
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
/// execution. We additionally emit Tauri events so the frontend can track when
/// the execution finishes.
#[tauri::command]
pub async fn hand_approve(
app: AppHandle,
state: State<'_, KernelState>,
hand_name: String,
run_id: String,
@@ -1301,6 +1404,66 @@ pub async fn hand_approve(
kernel.respond_to_approval(&run_id, approved, reason).await
.map_err(|e| format!("Failed to approve hand: {}", e))?;
// When approved, monitor the Hand execution and emit events to the frontend
if approved {
let approval_id = run_id.clone();
let hand_id = hand_name.clone();
let kernel_state: KernelState = (*state).clone();
tokio::spawn(async move {
// Poll the approval status until it transitions from "approved" to
// "completed" or "failed" (set by the kernel's spawned task).
// Timeout after 5 minutes to avoid hanging forever.
let timeout = tokio::time::Duration::from_secs(300);
let poll_interval = tokio::time::Duration::from_millis(500);
let result = tokio::time::timeout(timeout, async {
loop {
tokio::time::sleep(poll_interval).await;
let kernel_lock = kernel_state.lock().await;
if let Some(kernel) = kernel_lock.as_ref() {
// Use get_approval to check any status (not just "pending")
if let Some(entry) = kernel.get_approval(&approval_id).await {
match entry.status.as_str() {
"completed" => {
tracing::info!("[hand_approve] Hand '{}' execution completed for approval {}", hand_id, approval_id);
return (true, None::<String>);
}
"failed" => {
let error_msg = entry.input.get("error")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error")
.to_string();
tracing::warn!("[hand_approve] Hand '{}' execution failed for approval {}: {}", hand_id, approval_id, error_msg);
return (false, Some(error_msg));
}
_ => {} // still running (status is "approved")
}
} else {
// Entry disappeared entirely — kernel was likely restarted
return (false, Some("Approval entry disappeared".to_string()));
}
} else {
return (false, Some("Kernel not available".to_string()));
}
}
}).await;
let (success, error) = match result {
Ok((s, e)) => (s, e),
Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())),
};
let _ = app.emit("hand-execution-complete", serde_json::json!({
"approvalId": approval_id,
"handId": hand_id,
"success": success,
"error": error,
}));
});
}
Ok(serde_json::json!({
"status": if approved { "approved" } else { "rejected" },
"hand_name": hand_name,

View File

@@ -633,7 +633,8 @@ export function AuditLogsPanel() {
setVerificationResult(null);
try {
// Call ZCLAW API to verify the chain
// Call ZCLAW API to verify the chain (only available on GatewayClient)
if (!('verifyAuditLogChain' in client)) throw new Error('KernelClient does not support chain verification');
const result = await client.verifyAuditLogChain(log.id);
const verification: MerkleVerificationResult = {

View File

@@ -86,6 +86,7 @@ function ZclawLogo({ className }: { className?: string }) {
/** 根据运行环境自动选择 SaaS 服务器地址 */
function getSaasUrl(): string {
if (import.meta.env.DEV) return DEV_SAAS_URL;
return isTauriRuntime() ? PRODUCTION_SAAS_URL : DEV_SAAS_URL;
}

View File

@@ -139,12 +139,12 @@ export function SaaSSettings() {
<CloudFeatureRow
name="团队协作"
description="与团队成员共享 Agent 和技能"
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
status={account?.role === 'admin' || account?.role === 'super_admin' ? 'active' : 'inactive'}
/>
<CloudFeatureRow
name="高级分析"
description="使用统计和用量分析仪表板"
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
status={account?.role === 'admin' || account?.role === 'super_admin' ? 'active' : 'inactive'}
/>
</div>
</div>

View File

@@ -23,8 +23,8 @@ export interface SaaSAccountInfo {
username: string;
email: string;
display_name: string;
role: string;
status: string;
role: 'super_admin' | 'admin' | 'user';
status: 'active' | 'disabled' | 'suspended';
totp_enabled: boolean;
created_at: string;
}
@@ -64,6 +64,7 @@ export interface SaaSErrorResponse {
/** Login response from POST /api/v1/auth/login */
export interface SaaSLoginResponse {
token: string;
refresh_token: string;
account: SaaSAccountInfo;
}
@@ -322,8 +323,8 @@ export interface AccountPublic {
username: string;
email: string;
display_name: string;
role: string;
status: string;
role: 'super_admin' | 'admin' | 'user';
status: 'active' | 'disabled' | 'suspended';
totp_enabled: boolean;
last_login_at: string | null;
created_at: string;

View File

@@ -352,8 +352,11 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
// === SaaS Relay Mode ===
// Check connection mode from localStorage (set by saasStore).
// This takes priority over Tauri/Gateway when the user has selected SaaS mode.
// When SaaS is unreachable, gracefully degrade to local kernel mode
// so the desktop app remains functional.
const savedMode = localStorage.getItem('zclaw-connection-mode');
let saasDegraded = false;
if (savedMode === 'saas') {
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
const session = loadSaaSSession();
@@ -379,13 +382,26 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
useSaaSStore.getState().logout();
throw new Error('SaaS 会话已过期,请重新登录');
}
// SaaS unreachable — degrade to local kernel mode
const errMsg = err instanceof Error ? err.message : String(err);
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
log.warn(`SaaS 平台连接失败: ${errMsg} — 降级到本地 Kernel 模式`);
// Mark SaaS as unreachable in store
try {
const { useSaaSStore } = await import('./saasStore');
useSaaSStore.setState({ saasReachable: false });
} catch { /* non-critical */ }
saasDegraded = true;
}
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
log.debug('Connected to SaaS relay');
return;
if (!saasDegraded) {
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
log.debug('Connected to SaaS relay');
return;
}
// Fall through to Tauri Kernel / Gateway mode
}
// === Internal Kernel Mode (Tauri) ===

View File

@@ -97,7 +97,9 @@ export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
// === Constants ===
const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
const DEFAULT_SAAS_URL = import.meta.env.DEV
? 'http://127.0.0.1:8080'
: 'https://saas.zclaw.com';
// === Helpers ===
@@ -218,14 +220,21 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
? err.message
: String(err);
const isNetworkError = message.includes('Failed to fetch')
const isTimeout = message.includes('signal timed out')
|| message.includes('Timeout')
|| message.includes('timed out')
|| message.includes('AbortError');
const isConnectionRefused = message.includes('Failed to fetch')
|| message.includes('NetworkError')
|| message.includes('ECONNREFUSED')
|| message.includes('timeout');
|| message.includes('connection refused');
const userMessage = isNetworkError
? `无法连接 SaaS 服务器: ${requestUrl}`
: message;
const userMessage = isTimeout
? `连接 SaaS 服务器超时,请确认后端服务正在运行: ${requestUrl}`
: isConnectionRefused
? `无法连接到 SaaS 服务器,请确认后端服务已启动: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
throw new Error(userMessage);
@@ -491,7 +500,6 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
const existing = localStorage.getItem(storageKey);
// Diff check: skip if local was modified since last pull
const lastPullKey = `zclaw-config-pull-ts.${config.category}.${config.key}`;
const dirtyKey = `zclaw-config-dirty.${config.category}.${config.key}`;
const lastPulledValue = localStorage.getItem(`zclaw-config-pulled.${config.category}.${config.key}`);