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
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:
321
desktop/src-tauri/src/intelligence/extraction_adapter.rs
Normal file
321
desktop/src-tauri/src/intelligence/extraction_adapter.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 _,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ===
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user