feat(audit): 审计修复第四轮 — 跨会话搜索、LLM压缩集成、Presentation渲染器
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
- S9: MessageSearch 新增 Session/Global 双模式,Global 调用 VikingStorage memory_search - M4b: LLM 压缩器集成到 kernel AgentLoop,支持 use_llm 配置切换 - M4c: 压缩时自动提取记忆到 VikingStorage (runtime + tauri 双路径) - H6: 新增 ChartRenderer(recharts)、Document/Slideshow 完整渲染 - 累计修复 23 项,整体完成度 ~72%,真实可用率 ~80%
This commit is contained in:
@@ -5,8 +5,18 @@
|
|||||||
//! token count exceeds the configured threshold, older messages are
|
//! token count exceeds the configured threshold, older messages are
|
||||||
//! summarized into a single system message and only recent messages are
|
//! summarized into a single system message and only recent messages are
|
||||||
//! retained.
|
//! retained.
|
||||||
|
//!
|
||||||
|
//! Supports two compaction modes:
|
||||||
|
//! - **Rule-based**: Heuristic topic extraction (default, no LLM needed)
|
||||||
|
//! - **LLM-based**: Uses an LLM driver to generate higher-quality summaries
|
||||||
|
//!
|
||||||
|
//! Optionally flushes old messages to the growth/memory system before discarding.
|
||||||
|
|
||||||
use zclaw_types::Message;
|
use std::sync::Arc;
|
||||||
|
use zclaw_types::{AgentId, Message, SessionId};
|
||||||
|
|
||||||
|
use crate::driver::{CompletionRequest, ContentBlock, LlmDriver};
|
||||||
|
use crate::growth::GrowthIntegration;
|
||||||
|
|
||||||
/// Number of recent messages to preserve after compaction.
|
/// Number of recent messages to preserve after compaction.
|
||||||
const DEFAULT_KEEP_RECENT: usize = 6;
|
const DEFAULT_KEEP_RECENT: usize = 6;
|
||||||
@@ -146,6 +156,272 @@ pub fn maybe_compact(messages: Vec<Message>, threshold: usize) -> Vec<Message> {
|
|||||||
compacted
|
compacted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for compaction behavior.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompactionConfig {
|
||||||
|
/// Use LLM for generating summaries instead of rule-based extraction.
|
||||||
|
pub use_llm: bool,
|
||||||
|
/// Fall back to rule-based summary if LLM fails.
|
||||||
|
pub llm_fallback_to_rules: bool,
|
||||||
|
/// Flush memories from old messages before discarding them.
|
||||||
|
pub memory_flush_enabled: bool,
|
||||||
|
/// Maximum tokens for LLM-generated summary.
|
||||||
|
pub summary_max_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompactionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
use_llm: false,
|
||||||
|
llm_fallback_to_rules: true,
|
||||||
|
memory_flush_enabled: false,
|
||||||
|
summary_max_tokens: 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of an async compaction operation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompactionOutcome {
|
||||||
|
/// The (possibly compacted) message list.
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
/// Number of messages removed during compaction.
|
||||||
|
pub removed_count: usize,
|
||||||
|
/// Number of memories flushed to the growth system.
|
||||||
|
pub flushed_memories: usize,
|
||||||
|
/// Whether LLM was used for summary generation.
|
||||||
|
pub used_llm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async compaction with optional LLM summary and memory flushing.
|
||||||
|
///
|
||||||
|
/// When `messages` exceed `threshold` tokens:
|
||||||
|
/// 1. If `memory_flush_enabled`, extract memories from old messages via growth system
|
||||||
|
/// 2. Generate summary (LLM or rule-based depending on config)
|
||||||
|
/// 3. Replace old messages with summary + keep recent messages
|
||||||
|
pub async fn maybe_compact_with_config(
|
||||||
|
messages: Vec<Message>,
|
||||||
|
threshold: usize,
|
||||||
|
config: &CompactionConfig,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
session_id: &SessionId,
|
||||||
|
driver: Option<&Arc<dyn LlmDriver>>,
|
||||||
|
growth: Option<&GrowthIntegration>,
|
||||||
|
) -> CompactionOutcome {
|
||||||
|
let tokens = estimate_messages_tokens(&messages);
|
||||||
|
if tokens < threshold {
|
||||||
|
return CompactionOutcome {
|
||||||
|
messages,
|
||||||
|
removed_count: 0,
|
||||||
|
flushed_memories: 0,
|
||||||
|
used_llm: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[Compaction] Triggered: {} tokens > {} threshold, {} messages",
|
||||||
|
tokens,
|
||||||
|
threshold,
|
||||||
|
messages.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 1: Flush memories from messages that are about to be compacted
|
||||||
|
let flushed_memories = if config.memory_flush_enabled {
|
||||||
|
if let Some(growth) = growth {
|
||||||
|
match growth
|
||||||
|
.process_conversation(agent_id, &messages, session_id.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(count) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[Compaction] Flushed {} memories before compaction",
|
||||||
|
count
|
||||||
|
);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[Compaction] Memory flush failed: {}", e);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!("[Compaction] Memory flush requested but no growth integration available");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Determine split point (same logic as compact_messages)
|
||||||
|
let leading_system_count = messages
|
||||||
|
.iter()
|
||||||
|
.take_while(|m| matches!(m, Message::System { .. }))
|
||||||
|
.count();
|
||||||
|
let keep_from_end = DEFAULT_KEEP_RECENT
|
||||||
|
.min(messages.len().saturating_sub(leading_system_count));
|
||||||
|
let split_index = messages.len().saturating_sub(keep_from_end);
|
||||||
|
let split_index = split_index.max(leading_system_count);
|
||||||
|
|
||||||
|
if split_index == 0 {
|
||||||
|
return CompactionOutcome {
|
||||||
|
messages,
|
||||||
|
removed_count: 0,
|
||||||
|
flushed_memories,
|
||||||
|
used_llm: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_messages = &messages[..split_index];
|
||||||
|
let recent_messages = &messages[split_index..];
|
||||||
|
let removed_count = old_messages.len();
|
||||||
|
|
||||||
|
// Step 3: Generate summary (LLM or rule-based)
|
||||||
|
let summary = if config.use_llm {
|
||||||
|
if let Some(driver) = driver {
|
||||||
|
match generate_llm_summary(driver, old_messages, config.summary_max_tokens).await {
|
||||||
|
Ok(llm_summary) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[Compaction] Generated LLM summary ({} chars)",
|
||||||
|
llm_summary.len()
|
||||||
|
);
|
||||||
|
llm_summary
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if config.llm_fallback_to_rules {
|
||||||
|
tracing::warn!(
|
||||||
|
"[Compaction] LLM summary failed: {}, falling back to rules",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
generate_summary(old_messages)
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"[Compaction] LLM summary failed: {}, returning original messages",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return CompactionOutcome {
|
||||||
|
messages,
|
||||||
|
removed_count: 0,
|
||||||
|
flushed_memories,
|
||||||
|
used_llm: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"[Compaction] LLM compaction requested but no driver available, using rules"
|
||||||
|
);
|
||||||
|
generate_summary(old_messages)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
generate_summary(old_messages)
|
||||||
|
};
|
||||||
|
|
||||||
|
let used_llm = config.use_llm && driver.is_some();
|
||||||
|
|
||||||
|
// Step 4: Build compacted message list
|
||||||
|
let mut compacted = Vec::with_capacity(1 + recent_messages.len());
|
||||||
|
compacted.push(Message::system(summary));
|
||||||
|
compacted.extend(recent_messages.iter().cloned());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"[Compaction] Removed {} messages, {} remain (llm={})",
|
||||||
|
removed_count,
|
||||||
|
compacted.len(),
|
||||||
|
used_llm,
|
||||||
|
);
|
||||||
|
|
||||||
|
CompactionOutcome {
|
||||||
|
messages: compacted,
|
||||||
|
removed_count,
|
||||||
|
flushed_memories,
|
||||||
|
used_llm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a summary using an LLM driver.
|
||||||
|
async fn generate_llm_summary(
|
||||||
|
driver: &Arc<dyn LlmDriver>,
|
||||||
|
messages: &[Message],
|
||||||
|
max_tokens: u32,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut conversation_text = String::new();
|
||||||
|
for msg in messages {
|
||||||
|
match msg {
|
||||||
|
Message::User { content } => {
|
||||||
|
conversation_text.push_str(&format!("用户: {}\n", content))
|
||||||
|
}
|
||||||
|
Message::Assistant { content, .. } => {
|
||||||
|
conversation_text.push_str(&format!("助手: {}\n", content))
|
||||||
|
}
|
||||||
|
Message::System { content } => {
|
||||||
|
if !content.starts_with("[以下是之前对话的摘要]") {
|
||||||
|
conversation_text.push_str(&format!("[系统]: {}\n", content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::ToolUse { tool, input, .. } => {
|
||||||
|
conversation_text.push_str(&format!(
|
||||||
|
"[工具调用 {}]: {}\n",
|
||||||
|
tool.as_str(),
|
||||||
|
input
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Message::ToolResult { output, .. } => {
|
||||||
|
conversation_text.push_str(&format!("[工具结果]: {}\n", output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate conversation text if too long for the prompt itself
|
||||||
|
let max_conversation_chars = 8000;
|
||||||
|
if conversation_text.len() > max_conversation_chars {
|
||||||
|
conversation_text.truncate(max_conversation_chars);
|
||||||
|
conversation_text.push_str("\n...(对话已截断)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
"请用简洁的中文总结以下对话的关键信息。保留重要的讨论主题、决策、结论和待办事项。\
|
||||||
|
输出格式为段落式摘要,不超过200字。\n\n{}",
|
||||||
|
conversation_text
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = CompletionRequest {
|
||||||
|
model: String::new(),
|
||||||
|
system: Some(
|
||||||
|
"你是一个对话摘要助手。只输出摘要内容,不要添加额外解释。".to_string(),
|
||||||
|
),
|
||||||
|
messages: vec![Message::user(&prompt)],
|
||||||
|
tools: Vec::new(),
|
||||||
|
max_tokens: Some(max_tokens),
|
||||||
|
temperature: Some(0.3),
|
||||||
|
stop: Vec::new(),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = driver
|
||||||
|
.complete(request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{}", e))?;
|
||||||
|
|
||||||
|
// Extract text from content blocks
|
||||||
|
let text_parts: Vec<String> = response
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let summary = text_parts.join("");
|
||||||
|
|
||||||
|
if summary.is_empty() {
|
||||||
|
return Err("LLM returned empty response".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate a rule-based summary of old messages.
|
/// Generate a rule-based summary of old messages.
|
||||||
fn generate_summary(messages: &[Message]) -> String {
|
fn generate_summary(messages: &[Message]) -> String {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ pub use loop_runner::{AgentLoop, AgentLoopResult, LoopEvent};
|
|||||||
pub use loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardResult};
|
pub use loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardResult};
|
||||||
pub use stream::{StreamEvent, StreamSender};
|
pub use stream::{StreamEvent, StreamSender};
|
||||||
pub use growth::GrowthIntegration;
|
pub use growth::GrowthIntegration;
|
||||||
|
pub use compaction::{CompactionConfig, CompactionOutcome};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::tool::{ToolRegistry, ToolContext, SkillExecutor};
|
|||||||
use crate::tool::builtin::PathValidator;
|
use crate::tool::builtin::PathValidator;
|
||||||
use crate::loop_guard::{LoopGuard, LoopGuardResult};
|
use crate::loop_guard::{LoopGuard, LoopGuardResult};
|
||||||
use crate::growth::GrowthIntegration;
|
use crate::growth::GrowthIntegration;
|
||||||
use crate::compaction;
|
use crate::compaction::{self, CompactionConfig};
|
||||||
use zclaw_memory::MemoryStore;
|
use zclaw_memory::MemoryStore;
|
||||||
|
|
||||||
/// Agent loop runner
|
/// Agent loop runner
|
||||||
@@ -32,6 +32,8 @@ pub struct AgentLoop {
|
|||||||
growth: Option<GrowthIntegration>,
|
growth: Option<GrowthIntegration>,
|
||||||
/// Compaction threshold in tokens (0 = disabled)
|
/// Compaction threshold in tokens (0 = disabled)
|
||||||
compaction_threshold: usize,
|
compaction_threshold: usize,
|
||||||
|
/// Compaction behavior configuration
|
||||||
|
compaction_config: CompactionConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentLoop {
|
impl AgentLoop {
|
||||||
@@ -55,6 +57,7 @@ impl AgentLoop {
|
|||||||
path_validator: None,
|
path_validator: None,
|
||||||
growth: None,
|
growth: None,
|
||||||
compaction_threshold: 0,
|
compaction_threshold: 0,
|
||||||
|
compaction_config: CompactionConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +118,12 @@ impl AgentLoop {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set compaction configuration (LLM mode, memory flushing, etc.)
|
||||||
|
pub fn with_compaction_config(mut self, config: CompactionConfig) -> Self {
|
||||||
|
self.compaction_config = config;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Get growth integration reference
|
/// Get growth integration reference
|
||||||
pub fn growth(&self) -> Option<&GrowthIntegration> {
|
pub fn growth(&self) -> Option<&GrowthIntegration> {
|
||||||
self.growth.as_ref()
|
self.growth.as_ref()
|
||||||
@@ -150,7 +159,23 @@ impl AgentLoop {
|
|||||||
|
|
||||||
// Apply compaction if threshold is configured
|
// Apply compaction if threshold is configured
|
||||||
if self.compaction_threshold > 0 {
|
if self.compaction_threshold > 0 {
|
||||||
messages = compaction::maybe_compact(messages, self.compaction_threshold);
|
let needs_async =
|
||||||
|
self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled;
|
||||||
|
if needs_async {
|
||||||
|
let outcome = compaction::maybe_compact_with_config(
|
||||||
|
messages,
|
||||||
|
self.compaction_threshold,
|
||||||
|
&self.compaction_config,
|
||||||
|
&self.agent_id,
|
||||||
|
&session_id,
|
||||||
|
Some(&self.driver),
|
||||||
|
self.growth.as_ref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
messages = outcome.messages;
|
||||||
|
} else {
|
||||||
|
messages = compaction::maybe_compact(messages, self.compaction_threshold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhance system prompt with growth memories
|
// Enhance system prompt with growth memories
|
||||||
@@ -316,7 +341,23 @@ impl AgentLoop {
|
|||||||
|
|
||||||
// Apply compaction if threshold is configured
|
// Apply compaction if threshold is configured
|
||||||
if self.compaction_threshold > 0 {
|
if self.compaction_threshold > 0 {
|
||||||
messages = compaction::maybe_compact(messages, self.compaction_threshold);
|
let needs_async =
|
||||||
|
self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled;
|
||||||
|
if needs_async {
|
||||||
|
let outcome = compaction::maybe_compact_with_config(
|
||||||
|
messages,
|
||||||
|
self.compaction_threshold,
|
||||||
|
&self.compaction_config,
|
||||||
|
&self.agent_id,
|
||||||
|
&session_id,
|
||||||
|
Some(&self.driver),
|
||||||
|
self.growth.as_ref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
messages = outcome.messages;
|
||||||
|
} else {
|
||||||
|
messages = compaction::maybe_compact(messages, self.compaction_threshold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhance system prompt with growth memories
|
// Enhance system prompt with growth memories
|
||||||
|
|||||||
@@ -45,7 +45,10 @@
|
|||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
|
|||||||
1230
desktop/pnpm-lock.yaml
generated
1230
desktop/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -539,14 +539,26 @@ pub fn compactor_check_threshold(
|
|||||||
|
|
||||||
/// Execute compaction
|
/// Execute compaction
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn compactor_compact(
|
pub async fn compactor_compact(
|
||||||
messages: Vec<CompactableMessage>,
|
messages: Vec<CompactableMessage>,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
config: Option<CompactionConfig>,
|
config: Option<CompactionConfig>,
|
||||||
) -> CompactionResult {
|
) -> CompactionResult {
|
||||||
|
let memory_flush = config
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.memory_flush_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let flushed = if memory_flush {
|
||||||
|
flush_old_messages_to_memory(&messages, &agent_id, conversation_id.as_deref()).await
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
let compactor = ContextCompactor::new(config);
|
let compactor = ContextCompactor::new(config);
|
||||||
compactor.compact(&messages, &agent_id, conversation_id.as_deref())
|
let mut result = compactor.compact(&messages, &agent_id, conversation_id.as_deref());
|
||||||
|
result.flushed_memories = flushed;
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute compaction with optional LLM-based summary
|
/// Execute compaction with optional LLM-based summary
|
||||||
@@ -558,10 +570,95 @@ pub async fn compactor_compact_llm(
|
|||||||
compaction_config: Option<CompactionConfig>,
|
compaction_config: Option<CompactionConfig>,
|
||||||
llm_config: Option<LlmSummaryConfig>,
|
llm_config: Option<LlmSummaryConfig>,
|
||||||
) -> CompactionResult {
|
) -> CompactionResult {
|
||||||
|
let memory_flush = compaction_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.memory_flush_enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let flushed = if memory_flush {
|
||||||
|
flush_old_messages_to_memory(&messages, &agent_id, conversation_id.as_deref()).await
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
let compactor = ContextCompactor::new(compaction_config);
|
let compactor = ContextCompactor::new(compaction_config);
|
||||||
compactor
|
let mut result = compactor
|
||||||
.compact_with_llm(&messages, &agent_id, conversation_id.as_deref(), llm_config.as_ref())
|
.compact_with_llm(&messages, &agent_id, conversation_id.as_deref(), llm_config.as_ref())
|
||||||
.await
|
.await;
|
||||||
|
result.flushed_memories = flushed;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush important messages from the old (pre-compaction) portion to VikingStorage.
|
||||||
|
///
|
||||||
|
/// Extracts user messages and key assistant responses as session memories
|
||||||
|
/// so that information is preserved even after messages are compacted away.
|
||||||
|
async fn flush_old_messages_to_memory(
|
||||||
|
messages: &[CompactableMessage],
|
||||||
|
agent_id: &str,
|
||||||
|
_conversation_id: Option<&str>,
|
||||||
|
) -> usize {
|
||||||
|
let storage = match crate::viking_commands::get_storage().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[Compactor] Cannot get storage for memory flush: {}", e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut flushed = 0usize;
|
||||||
|
let mut prev_was_user = false;
|
||||||
|
|
||||||
|
for msg in messages {
|
||||||
|
// Flush user messages as session memories (they contain user intent/preferences)
|
||||||
|
if msg.role == "user" && msg.content.len() > 10 {
|
||||||
|
let entry = zclaw_growth::MemoryEntry::new(
|
||||||
|
agent_id,
|
||||||
|
zclaw_growth::MemoryType::Session,
|
||||||
|
"compaction_flush",
|
||||||
|
msg.content.clone(),
|
||||||
|
)
|
||||||
|
.with_importance(4);
|
||||||
|
|
||||||
|
match zclaw_growth::VikingStorage::store(storage.as_ref(), &entry).await {
|
||||||
|
Ok(_) => flushed += 1,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("[Compactor] Memory flush failed for user msg: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_was_user = true;
|
||||||
|
} else if msg.role == "assistant" && prev_was_user {
|
||||||
|
// Flush the assistant response that follows a user message (contains answers)
|
||||||
|
if msg.content.len() > 20 {
|
||||||
|
let entry = zclaw_growth::MemoryEntry::new(
|
||||||
|
agent_id,
|
||||||
|
zclaw_growth::MemoryType::Session,
|
||||||
|
"compaction_flush",
|
||||||
|
msg.content.clone(),
|
||||||
|
)
|
||||||
|
.with_importance(3);
|
||||||
|
|
||||||
|
match zclaw_growth::VikingStorage::store(storage.as_ref(), &entry).await {
|
||||||
|
Ok(_) => flushed += 1,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("[Compactor] Memory flush failed for assistant msg: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_was_user = false;
|
||||||
|
} else {
|
||||||
|
prev_was_user = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if flushed > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"[Compactor] Flushed {} memories before compaction for agent {}",
|
||||||
|
flushed,
|
||||||
|
agent_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter } from 'lucide-react';
|
import { Search, X, ChevronUp, ChevronDown, Clock, User, Filter, Globe, MessageSquare } from 'lucide-react';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
import { useChatStore, Message } from '../store/chatStore';
|
import { useChatStore, Message } from '../store/chatStore';
|
||||||
|
import { intelligence, PersistentMemory } from '../lib/intelligence-backend';
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
sender: 'all' | 'user' | 'assistant';
|
sender: 'all' | 'user' | 'assistant';
|
||||||
timeRange: 'all' | 'today' | 'week' | 'month';
|
timeRange: 'all' | 'today' | 'week' | 'month';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SearchScope = 'session' | 'global';
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
message: Message;
|
message: Message;
|
||||||
matchIndices: Array<{ start: number; end: number }>;
|
matchIndices: Array<{ start: number; end: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlobalSearchResult {
|
||||||
|
memory: PersistentMemory;
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageSearchProps {
|
interface MessageSearchProps {
|
||||||
onNavigateToMessage: (messageId: string) => void;
|
onNavigateToMessage: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -26,6 +33,7 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [scope, setScope] = useState<SearchScope>('session');
|
||||||
const [filters, setFilters] = useState<SearchFilters>({
|
const [filters, setFilters] = useState<SearchFilters>({
|
||||||
sender: 'all',
|
sender: 'all',
|
||||||
timeRange: 'all',
|
timeRange: 'all',
|
||||||
@@ -33,6 +41,8 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||||
|
const [globalResults, setGlobalResults] = useState<GlobalSearchResult[]>([]);
|
||||||
|
const [globalLoading, setGlobalLoading] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Load search history from localStorage
|
// Load search history from localStorage
|
||||||
@@ -63,6 +73,41 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Global search: query VikingStorage when scope is 'global'
|
||||||
|
useEffect(() => {
|
||||||
|
if (scope !== 'global' || !query.trim()) {
|
||||||
|
setGlobalResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const debounceTimer = setTimeout(async () => {
|
||||||
|
setGlobalLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await intelligence.memory.search({
|
||||||
|
query: query.trim(),
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
setGlobalResults(results.map((memory) => ({ memory })));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setGlobalResults([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setGlobalLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
};
|
||||||
|
}, [scope, query]);
|
||||||
|
|
||||||
// Filter messages by time range
|
// Filter messages by time range
|
||||||
const filterByTimeRange = useCallback((message: Message, timeRange: SearchFilters['timeRange']): boolean => {
|
const filterByTimeRange = useCallback((message: Message, timeRange: SearchFilters['timeRange']): boolean => {
|
||||||
if (timeRange === 'all') return true;
|
if (timeRange === 'all') return true;
|
||||||
@@ -245,6 +290,36 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
>
|
>
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||||
|
{/* Scope toggle */}
|
||||||
|
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScope('session')}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||||
|
scope === 'session'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
aria-label="Search current session"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
<span className="hidden sm:inline">Session</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScope('global')}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
|
||||||
|
scope === 'global'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-orange-600 dark:text-orange-400 shadow-sm'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
aria-label="Search all memories"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
<span className="hidden sm:inline">Global</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
@@ -253,7 +328,7 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search messages..."
|
placeholder={scope === 'global' ? 'Search all memories...' : 'Search messages...'}
|
||||||
className="w-full pl-9 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400 focus:border-transparent"
|
className="w-full pl-9 pr-8 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 dark:focus:ring-orange-400 focus:border-transparent"
|
||||||
aria-label="Search query"
|
aria-label="Search query"
|
||||||
/>
|
/>
|
||||||
@@ -269,22 +344,24 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter toggle */}
|
{/* Filter toggle (session only) */}
|
||||||
<Button
|
{scope === 'session' && (
|
||||||
type="button"
|
<Button
|
||||||
variant={showFilters ? 'secondary' : 'ghost'}
|
type="button"
|
||||||
size="sm"
|
variant={showFilters ? 'secondary' : 'ghost'}
|
||||||
onClick={() => setShowFilters((prev) => !prev)}
|
size="sm"
|
||||||
className="flex items-center gap-1"
|
onClick={() => setShowFilters((prev) => !prev)}
|
||||||
aria-label="Toggle filters"
|
className="flex items-center gap-1"
|
||||||
aria-expanded={showFilters}
|
aria-label="Toggle filters"
|
||||||
>
|
aria-expanded={showFilters}
|
||||||
<Filter className="w-4 h-4" />
|
>
|
||||||
<span className="hidden sm:inline">Filters</span>
|
<Filter className="w-4 h-4" />
|
||||||
</Button>
|
<span className="hidden sm:inline">Filters</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
{/* Navigation buttons (session only) */}
|
||||||
{searchResults.length > 0 && (
|
{scope === 'session' && searchResults.length > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">
|
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">
|
||||||
{currentMatchIndex + 1} / {searchResults.length}
|
{currentMatchIndex + 1} / {searchResults.length}
|
||||||
@@ -381,8 +458,58 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No results message */}
|
{/* Global search results */}
|
||||||
{query && searchResults.length === 0 && (
|
{scope === 'global' && query && (
|
||||||
|
<div className="mt-2 max-h-64 overflow-y-auto">
|
||||||
|
{globalLoading && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
Searching memories...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!globalLoading && globalResults.length === 0 && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
No memories found matching "{query}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!globalLoading && globalResults.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">
|
||||||
|
{globalResults.length} memories found
|
||||||
|
</div>
|
||||||
|
{globalResults.map((result) => (
|
||||||
|
<div
|
||||||
|
key={result.memory.id}
|
||||||
|
className="px-2 py-1.5 bg-white dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600 rounded text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span className="text-orange-500 dark:text-orange-400 font-medium">
|
||||||
|
{result.memory.memory_type}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{result.memory.agent_id}
|
||||||
|
</span>
|
||||||
|
{result.memory.importance > 5 && (
|
||||||
|
<span className="text-yellow-500">
|
||||||
|
{'*'.repeat(Math.min(result.memory.importance - 4, 5))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||||
|
{highlightSearchMatches(result.memory.content, query)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{result.memory.created_at.split('T')[0]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results message (session search) */}
|
||||||
|
{scope === 'session' && query && searchResults.length === 0 && (
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
No messages found matching "{query}"
|
No messages found matching "{query}"
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ export function ReflectionLog({
|
|||||||
// Initialize reflection engine with config that allows soul modification
|
// Initialize reflection engine with config that allows soul modification
|
||||||
await intelligenceClient.reflection.init(config);
|
await intelligenceClient.reflection.init(config);
|
||||||
|
|
||||||
const loadedHistory = await intelligenceClient.reflection.getHistory();
|
const loadedHistory = await intelligenceClient.reflection.getHistory(undefined, agentId);
|
||||||
setHistory([...loadedHistory].reverse()); // Most recent first
|
setHistory([...loadedHistory].reverse()); // Most recent first
|
||||||
|
|
||||||
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
|
const proposals = await intelligenceClient.identity.getPendingProposals(agentId);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { QuizRenderer } from './renderers/QuizRenderer';
|
|||||||
|
|
||||||
const SlideshowRenderer = React.lazy(() => import('./renderers/SlideshowRenderer').then(m => ({ default: m.SlideshowRenderer })));
|
const SlideshowRenderer = React.lazy(() => import('./renderers/SlideshowRenderer').then(m => ({ default: m.SlideshowRenderer })));
|
||||||
const DocumentRenderer = React.lazy(() => import('./renderers/DocumentRenderer').then(m => ({ default: m.DocumentRenderer })));
|
const DocumentRenderer = React.lazy(() => import('./renderers/DocumentRenderer').then(m => ({ default: m.DocumentRenderer })));
|
||||||
|
const ChartRenderer = React.lazy(() => import('./renderers/ChartRenderer').then(m => ({ default: m.ChartRenderer })));
|
||||||
|
|
||||||
interface PresentationContainerProps {
|
interface PresentationContainerProps {
|
||||||
/** Pipeline output data */
|
/** Pipeline output data */
|
||||||
@@ -78,7 +79,7 @@ export function PresentationContainer({
|
|||||||
if (supportedTypes && supportedTypes.length > 0) {
|
if (supportedTypes && supportedTypes.length > 0) {
|
||||||
return supportedTypes.filter((t): t is PresentationType => t !== 'auto');
|
return supportedTypes.filter((t): t is PresentationType => t !== 'auto');
|
||||||
}
|
}
|
||||||
return (['quiz', 'slideshow', 'document', 'whiteboard'] as PresentationType[]);
|
return (['quiz', 'slideshow', 'document', 'chart', 'whiteboard'] as PresentationType[]);
|
||||||
}, [supportedTypes]);
|
}, [supportedTypes]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
@@ -111,11 +112,21 @@ export function PresentationContainer({
|
|||||||
|
|
||||||
case 'whiteboard':
|
case 'whiteboard':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 bg-gray-50">
|
<div className="flex flex-col items-center justify-center h-64 bg-gray-50 gap-3">
|
||||||
<p className="text-gray-500">白板渲染器开发中...</p>
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||||
|
即将推出
|
||||||
|
</span>
|
||||||
|
<p className="text-gray-500">白板渲染器开发中</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'chart':
|
||||||
|
return (
|
||||||
|
<React.Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
|
||||||
|
<ChartRenderer data={data as Parameters<typeof ChartRenderer>[0]['data']} />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 bg-gray-50">
|
<div className="flex items-center justify-center h-64 bg-gray-50">
|
||||||
|
|||||||
204
desktop/src/components/presentation/renderers/ChartRenderer.tsx
Normal file
204
desktop/src/components/presentation/renderers/ChartRenderer.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Chart Renderer
|
||||||
|
*
|
||||||
|
* Renders data as interactive charts using recharts.
|
||||||
|
* Supports: line, bar, pie, scatter, area chart types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
||||||
|
ScatterChart, Scatter, AreaChart, Area,
|
||||||
|
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { ChartData } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_COLORS = [
|
||||||
|
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6',
|
||||||
|
'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ChartRendererProps {
|
||||||
|
data: ChartData;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartRenderer({ data, className = '' }: ChartRendererProps) {
|
||||||
|
const { type, title, labels, datasets, options } = data;
|
||||||
|
|
||||||
|
// Transform datasets + labels into recharts data format
|
||||||
|
const chartData = (labels || []).map((label, i) => {
|
||||||
|
const point: Record<string, string | number> = { name: label };
|
||||||
|
for (const ds of datasets) {
|
||||||
|
point[ds.label] = ds.data[i] ?? 0;
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no labels, use index as x-axis
|
||||||
|
if (!labels || labels.length === 0) {
|
||||||
|
const maxLen = Math.max(...datasets.map(ds => ds.data.length), 0);
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
const point: Record<string, string | number> = { name: `${i + 1}` };
|
||||||
|
for (const ds of datasets) {
|
||||||
|
point[ds.label] = ds.data[i] ?? 0;
|
||||||
|
}
|
||||||
|
chartData.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLegend = options?.plugins?.legend?.display !== false;
|
||||||
|
|
||||||
|
const legendProps = showLegend
|
||||||
|
? { wrapperStyle: { paddingBottom: 8 } }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const chartTitle = title || options?.plugins?.title?.text;
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'line':
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="name" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
{showLegend && <Legend {...legendProps} />}
|
||||||
|
{datasets.map((ds, i) => (
|
||||||
|
<Line
|
||||||
|
key={ds.label}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={ds.label}
|
||||||
|
stroke={Array.isArray(ds.borderColor) ? ds.borderColor[0] : (ds.borderColor || DEFAULT_COLORS[i % DEFAULT_COLORS.length])}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
fill={Array.isArray(ds.backgroundColor) ? undefined : (ds.backgroundColor as string | undefined)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'bar':
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="name" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
{showLegend && <Legend {...legendProps} />}
|
||||||
|
{datasets.map((ds, i) => (
|
||||||
|
<Bar
|
||||||
|
key={ds.label}
|
||||||
|
dataKey={ds.label}
|
||||||
|
fill={Array.isArray(ds.backgroundColor) ? ds.backgroundColor[0] : (ds.backgroundColor || DEFAULT_COLORS[i % DEFAULT_COLORS.length])}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'pie': {
|
||||||
|
const pieData = datasets.flatMap((ds) =>
|
||||||
|
(labels || ds.data.map((_, i) => `${i + 1}`)).map((label, i) => ({
|
||||||
|
name: label,
|
||||||
|
value: ds.data[i] ?? 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine
|
||||||
|
label={({ name, percent }: { name?: string; percent?: number }) => `${name ?? ''} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||||
|
outerRadius="70%"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={DEFAULT_COLORS[i % DEFAULT_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
{showLegend && <Legend />}
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'scatter': {
|
||||||
|
const scatterData = datasets.flatMap((ds) =>
|
||||||
|
ds.data.map((val, i) => ({
|
||||||
|
x: labels ? i : i + 1,
|
||||||
|
y: val,
|
||||||
|
name: ds.label,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ScatterChart>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="x" name="X" fontSize={12} />
|
||||||
|
<YAxis dataKey="y" name="Y" fontSize={12} />
|
||||||
|
<Tooltip cursor={{ strokeDasharray: '3 3' }} />
|
||||||
|
{showLegend && <Legend />}
|
||||||
|
<Scatter name={datasets[0]?.label || '数据'} data={scatterData} fill={DEFAULT_COLORS[0]} />
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'area':
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="name" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
{showLegend && <Legend {...legendProps} />}
|
||||||
|
{datasets.map((ds, i) => (
|
||||||
|
<Area
|
||||||
|
key={ds.label}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={ds.label}
|
||||||
|
stroke={Array.isArray(ds.borderColor) ? ds.borderColor[0] : (ds.borderColor || DEFAULT_COLORS[i % DEFAULT_COLORS.length])}
|
||||||
|
fill={Array.isArray(ds.backgroundColor) ? ds.backgroundColor[0] : (ds.backgroundColor || `${DEFAULT_COLORS[i % DEFAULT_COLORS.length]}33`)}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <p className="text-gray-500 text-center">不支持的图表类型: {type}</p>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
|
{chartTitle && (
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{chartTitle}</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 p-4" style={{ minHeight: 300 }}>
|
||||||
|
{datasets.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-gray-400">暂无图表数据</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderChart()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChartRenderer;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Document Renderer
|
* Document Renderer
|
||||||
*
|
*
|
||||||
* Renders content as a scrollable document with Markdown support.
|
* Renders content as a scrollable document with full Markdown support
|
||||||
|
* via react-markdown + remark-gfm (tables, strikethrough, task lists, etc.).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Download, ExternalLink, Copy } from 'lucide-react';
|
import { Download, ExternalLink, Copy } from 'lucide-react';
|
||||||
import type { DocumentData } from '../types';
|
import type { DocumentData } from '../types';
|
||||||
|
|
||||||
@@ -26,12 +29,14 @@ export function DocumentRenderer({
|
|||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
const textToCopy = typeof data === 'string' ? data : (data.content || JSON.stringify(data, null, 2));
|
const textToCopy = typeof data === 'string'
|
||||||
|
? data
|
||||||
|
: (data.content || JSON.stringify(data, null, 2));
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to copy:', error);
|
// Clipboard API may not be available in all contexts
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,58 +51,14 @@ export function DocumentRenderer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMarkdown = (content: string): React.ReactNode => {
|
const content = typeof data === 'string'
|
||||||
const lines = content.split('\n');
|
? data
|
||||||
const elements: React.ReactNode[] = [];
|
: (data.content || JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
if (trimmed.startsWith('# ')) {
|
|
||||||
elements.push(
|
|
||||||
<h1 key={trimmed} className="text-2xl font-bold mb-4">
|
|
||||||
{trimmed.substring(2)}
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
} else if (trimmed.startsWith('## ')) {
|
|
||||||
elements.push(
|
|
||||||
<h2 key={trimmed} className="text-xl font-semibold mb-3">
|
|
||||||
{trimmed.substring(3)}
|
|
||||||
</h2>
|
|
||||||
);
|
|
||||||
} else if (trimmed.startsWith('### ')) {
|
|
||||||
elements.push(
|
|
||||||
<h3 key={trimmed} className="text-lg font-medium mb-2">
|
|
||||||
{trimmed.substring(4)}
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
} else if (trimmed.startsWith('- ')) {
|
|
||||||
elements.push(
|
|
||||||
<li key={trimmed} className="ml-4 list-disc">
|
|
||||||
{trimmed.substring(2)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
} else if (trimmed.startsWith('```')) {
|
|
||||||
elements.push(
|
|
||||||
<pre key={trimmed} className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm my-2">
|
|
||||||
<code>{trimmed.substring(3, trimmed.length - 3)}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
elements.push(
|
|
||||||
<p key={trimmed} className="mb-2">{trimmed}</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={className}>{elements}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!enableMarkdown) {
|
if (!enableMarkdown) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full ${className}`}>
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
<pre className="whitespace-pre-wrap text-sm">{JSON.stringify(data, null, 2)}</pre>
|
<pre className="whitespace-pre-wrap text-sm">{content}</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,10 +99,8 @@ export function DocumentRenderer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6 prose prose-gray max-w-none">
|
||||||
{typeof data === 'string'
|
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
|
||||||
? renderMarkdown(data)
|
|
||||||
: renderMarkdown(data.content || JSON.stringify(data))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
* Slideshow Renderer
|
* Slideshow Renderer
|
||||||
*
|
*
|
||||||
* Renders presentation as a slideshow with slide navigation.
|
* Renders presentation as a slideshow with slide navigation.
|
||||||
|
* Supports: title, content, image, code, twoColumn slide types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -13,7 +16,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SlideshowData } from '../types';
|
import type { SlideshowData, Slide } from '../types';
|
||||||
|
|
||||||
interface SlideshowRendererProps {
|
interface SlideshowRendererProps {
|
||||||
data: SlideshowData;
|
data: SlideshowData;
|
||||||
@@ -41,30 +44,6 @@ export function SlideshowRenderer({
|
|||||||
const slides = data.slides || [];
|
const slides = data.slides || [];
|
||||||
const totalSlides = slides.length;
|
const totalSlides = slides.length;
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
|
||||||
handleNext();
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
handlePrev();
|
|
||||||
} else if (e.key === 'f') {
|
|
||||||
toggleFullscreen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-play
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying && autoPlayInterval > 0) {
|
|
||||||
const timer = setInterval(handleNext, autoPlayInterval * 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}
|
|
||||||
}, [isPlaying, autoPlayInterval]);
|
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
const handleNext = useCallback(() => {
|
||||||
setCurrentIndex((prev) => (prev + 1) % totalSlides);
|
setCurrentIndex((prev) => (prev + 1) % totalSlides);
|
||||||
}, [totalSlides]);
|
}, [totalSlides]);
|
||||||
@@ -77,6 +56,32 @@ export function SlideshowRenderer({
|
|||||||
setIsFullscreen((prev) => !prev);
|
setIsFullscreen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNext();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePrev();
|
||||||
|
} else if (e.key === 'f') {
|
||||||
|
toggleFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleNext, handlePrev, toggleFullscreen]);
|
||||||
|
|
||||||
|
// Auto-play
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying && autoPlayInterval > 0) {
|
||||||
|
const timer = setInterval(handleNext, autoPlayInterval * 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [isPlaying, autoPlayInterval, handleNext]);
|
||||||
|
|
||||||
const currentSlide = slides[currentIndex];
|
const currentSlide = slides[currentIndex];
|
||||||
|
|
||||||
if (!currentSlide) {
|
if (!currentSlide) {
|
||||||
@@ -88,26 +93,15 @@ export function SlideshowRenderer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''} ${className}`}>
|
<div
|
||||||
|
className={`flex flex-col h-full ${
|
||||||
|
isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
{/* Slide Content */}
|
{/* Slide Content */}
|
||||||
<div className="flex-1 flex items-center justify-center p-8">
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
<div className="max-w-4xl w-full">
|
<div className="max-w-4xl w-full">
|
||||||
{/* Title */}
|
<SlideContent slide={currentSlide} />
|
||||||
{currentSlide.title && (
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-6">
|
|
||||||
{currentSlide.title}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content rendering would go here */}
|
|
||||||
<div className="text-gray-700">
|
|
||||||
{/* This is simplified - real implementation would render based on content type */}
|
|
||||||
{typeof currentSlide.content === 'string' ? (
|
|
||||||
<p>{currentSlide.content}</p>
|
|
||||||
) : (
|
|
||||||
<div>Complex content rendering</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,7 +121,11 @@ export function SlideshowRenderer({
|
|||||||
disabled={autoPlayInterval === 0}
|
disabled={autoPlayInterval === 0}
|
||||||
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
|
className="p-2 hover:bg-gray-200 rounded disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
{isPlaying ? (
|
||||||
|
<Pause className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -162,11 +160,124 @@ export function SlideshowRenderer({
|
|||||||
{/* Speaker Notes */}
|
{/* Speaker Notes */}
|
||||||
{showNotes && currentSlide.notes && (
|
{showNotes && currentSlide.notes && (
|
||||||
<div className="p-4 bg-yellow-50 border-t text-sm text-gray-600">
|
<div className="p-4 bg-yellow-50 border-t text-sm text-gray-600">
|
||||||
📝 {currentSlide.notes}
|
{currentSlide.notes}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renders a single slide based on its type */
|
||||||
|
function SlideContent({ slide }: { slide: Slide }) {
|
||||||
|
switch (slide.type) {
|
||||||
|
case 'title':
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
{slide.title && (
|
||||||
|
<h1 className="text-4xl font-bold mb-4">{slide.title}</h1>
|
||||||
|
)}
|
||||||
|
{slide.content && (
|
||||||
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
{slide.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'content':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{slide.title && (
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-6">{slide.title}</h2>
|
||||||
|
)}
|
||||||
|
{slide.content && (
|
||||||
|
<div className="prose prose-gray max-w-none">
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>{slide.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
{slide.title && (
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
|
||||||
|
)}
|
||||||
|
{slide.image && (
|
||||||
|
<img
|
||||||
|
src={slide.image}
|
||||||
|
alt={slide.title || '幻灯片图片'}
|
||||||
|
className="max-w-full max-h-[60vh] mx-auto rounded-lg shadow-md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{slide.content && (
|
||||||
|
<p className="mt-4 text-gray-600">{slide.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'code':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{slide.title && (
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
|
||||||
|
)}
|
||||||
|
{slide.code && (
|
||||||
|
<pre className="bg-gray-900 text-gray-100 p-6 rounded-lg overflow-x-auto text-sm">
|
||||||
|
{slide.language && (
|
||||||
|
<div className="text-xs text-gray-400 mb-3 uppercase tracking-wider">
|
||||||
|
{slide.language}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<code>{slide.code}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{slide.content && (
|
||||||
|
<p className="mt-4 text-gray-600">{slide.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'twoColumn':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{slide.title && (
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
{slide.leftContent && (
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{slide.leftContent}
|
||||||
|
</Markdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
{slide.rightContent && (
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{slide.rightContent}
|
||||||
|
</Markdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{slide.title && (
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
|
||||||
|
)}
|
||||||
|
{slide.content && (
|
||||||
|
<div className="prose prose-gray max-w-none">
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>{slide.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default SlideshowRenderer;
|
export default SlideshowRenderer;
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ export const reflection = {
|
|||||||
return invoke('reflection_reflect', { agentId, memories });
|
return invoke('reflection_reflect', { agentId, memories });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHistory(limit?: number): Promise<ReflectionResult[]> {
|
async getHistory(limit?: number, agentId?: string): Promise<ReflectionResult[]> {
|
||||||
return invoke('reflection_get_history', { limit });
|
return invoke('reflection_get_history', { limit, agentId });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getState(): Promise<ReflectionState> {
|
async getState(): Promise<ReflectionState> {
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ const fallbackReflection = {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHistory(limit?: number): Promise<ReflectionResult[]> {
|
async getHistory(limit?: number, _agentId?: string): Promise<ReflectionResult[]> {
|
||||||
const l = limit ?? 10;
|
const l = limit ?? 10;
|
||||||
return fallbackReflection._history.slice(-l).reverse();
|
return fallbackReflection._history.slice(-l).reverse();
|
||||||
},
|
},
|
||||||
@@ -1318,13 +1318,13 @@ export const intelligenceClient = {
|
|||||||
return fallbackReflection.reflect(agentId, memories);
|
return fallbackReflection.reflect(agentId, memories);
|
||||||
},
|
},
|
||||||
|
|
||||||
getHistory: async (limit?: number): Promise<ReflectionResult[]> => {
|
getHistory: async (limit?: number, agentId?: string): Promise<ReflectionResult[]> => {
|
||||||
if (isTauriRuntime()) {
|
if (isTauriRuntime()) {
|
||||||
return tauriInvoke('reflection.getHistory', () =>
|
return tauriInvoke('reflection.getHistory', () =>
|
||||||
intelligence.reflection.getHistory(limit)
|
intelligence.reflection.getHistory(limit, agentId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return fallbackReflection.getHistory(limit);
|
return fallbackReflection.getHistory(limit, agentId);
|
||||||
},
|
},
|
||||||
|
|
||||||
getState: async (): Promise<ReflectionState> => {
|
getState: async (): Promise<ReflectionState> => {
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ PipelinesPanel.tsx → workflowStore.runPipeline()
|
|||||||
| **P2** | M5 | 自主授权集成到执行链路 | 1-2d | 下周 | ✅ 已修复 |
|
| **P2** | M5 | 自主授权集成到执行链路 | 1-2d | 下周 | ✅ 已修复 |
|
||||||
| **P2** | M3 | hand_approve 使用 hand_name 参数 | 1h | 下周 | ✅ 已修复 |
|
| **P2** | M3 | hand_approve 使用 hand_name 参数 | 1h | 下周 | ✅ 已修复 |
|
||||||
| **P2** | L2 | 清理 gatewayStore 废弃引用 | 1h | 下周 | ✅ 已确认(仅注释) |
|
| **P2** | L2 | 清理 gatewayStore 废弃引用 | 1h | 下周 | ✅ 已确认(仅注释) |
|
||||||
|
| **P2** | S9 | 消息搜索仅当前会话 — 新增 Global 模式 | 1d | 下周 | ✅ 已修复 |
|
||||||
| **P3** | M6 | 实现语义路由 | 2-3d | 下个迭代 |
|
| **P3** | M6 | 实现语义路由 | 2-3d | 下个迭代 |
|
||||||
| **P3** | L1 | Pipeline 并行执行 | 2d | 下个迭代 |
|
| **P3** | L1 | Pipeline 并行执行 | 2d | 下个迭代 |
|
||||||
| **P3** | L3 | Wasm/Native 技能模式 | 3-5d | 长期 |
|
| **P3** | L3 | Wasm/Native 技能模式 | 3-5d | 长期 |
|
||||||
@@ -570,6 +571,7 @@ ZCLAW 的核心架构(通信、状态管理、安全认证、聊天、Agent
|
|||||||
13. ~~**反思历史只存单条**~~ ✅ 已修复 — 累积存储到 reflection:history 数组
|
13. ~~**反思历史只存单条**~~ ✅ 已修复 — 累积存储到 reflection:history 数组
|
||||||
14. ~~**身份回滚 UI 缺失**~~ ✅ 已实现 — IdentityChangeProposal.tsx HistoryItem
|
14. ~~**身份回滚 UI 缺失**~~ ✅ 已实现 — IdentityChangeProposal.tsx HistoryItem
|
||||||
15. **28 处 dead_code 标注**中大部分是合理的预留功能,少数是遗留代码
|
15. **28 处 dead_code 标注**中大部分是合理的预留功能,少数是遗留代码
|
||||||
16. **剩余 P2/P3 项**: 反思 LLM 分析、跨会话搜索、语义路由、Pipeline 并行等
|
16. **剩余 P2/P3 项**: 反思 LLM 分析、语义路由、Pipeline 并行等
|
||||||
|
17. ~~**消息搜索仅当前会话**~~ ✅ 已修复 — MessageSearch 新增 Global 模式,调用 VikingStorage memory_search 跨会话搜索记忆
|
||||||
|
|
||||||
**累计修复 22 项** (P0×3 + P1×8 + P2×6 + 误判×2 + 审计×3),系统真实可用率从 ~50% 提升到 ~80%。剩余 P3 项为增强功能,不阻塞核心使用。
|
**累计修复 23 项** (P0×3 + P1×8 + P2×7 + 误判×2 + 审计×3),系统真实可用率从 ~50% 提升到 ~80%。剩余 P3 项为增强功能,不阻塞核心使用。
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# ZCLAW 功能全景文档
|
# ZCLAW 功能全景文档
|
||||||
|
|
||||||
> **版本**: v0.6.3
|
> **版本**: v0.6.4
|
||||||
> **更新日期**: 2026-03-27
|
> **更新日期**: 2026-03-27
|
||||||
> **项目状态**: 完整 Rust Workspace 架构,10 个核心 Crates,69 技能,Pipeline DSL + Smart Presentation + Agent Growth System
|
> **项目状态**: 完整 Rust Workspace 架构,10 个核心 Crates,69 技能,Pipeline DSL + Smart Presentation + Agent Growth System
|
||||||
> **整体完成度**: ~62% (基于 2026-03-27 深度审计 + 两轮修复后)
|
> **整体完成度**: ~72% (基于 2026-03-27 深度审计 + 四轮修复后)
|
||||||
> **架构**: Tauri 桌面应用,Rust Workspace (10 crates) + React 前端
|
> **架构**: Tauri 桌面应用,Rust Workspace (10 crates) + React 前端
|
||||||
>
|
>
|
||||||
> **审计修复 (2026-03-27)**: 修复 2 个 CRITICAL + 6 个 HIGH + 2 个 MEDIUM 问题,详见 [DEEP_AUDIT_REPORT.md](./DEEP_AUDIT_REPORT.md)
|
> **审计修复 (2026-03-27)**: 累计修复 23 项 (P0×3 + P1×8 + P2×7 + 误判×2 + 审计×3),详见 [DEEP_AUDIT_REPORT.md](./DEEP_AUDIT_REPORT.md)
|
||||||
|
|
||||||
> **重要**: ZCLAW 采用 Rust Workspace 架构,包含 10 个分层 Crates (types → memory → runtime → kernel → skills/hands/protocols/pipeline/growth/channels),所有核心能力集成在 Tauri 桌面应用中
|
> **重要**: ZCLAW 采用 Rust Workspace 架构,包含 10 个分层 Crates (types → memory → runtime → kernel → skills/hands/protocols/pipeline/growth/channels),所有核心能力集成在 Tauri 桌面应用中
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
| 文档 | 功能 | 成熟度 | 测试覆盖 |
|
| 文档 | 功能 | 成熟度 | 测试覆盖 |
|
||||||
|------|------|--------|---------|
|
|------|------|--------|---------|
|
||||||
| [00-chat-interface.md](01-core-features/00-chat-interface.md) | 聊天界面 | L3-L4 (90%) | 高 |
|
| [00-chat-interface.md](01-core-features/00-chat-interface.md) | 聊天界面 | L4 (92%) | 高 |
|
||||||
| [01-agent-clones.md](01-core-features/01-agent-clones.md) | Agent 分身 | L3 (85%) | 高 |
|
| [01-agent-clones.md](01-core-features/01-agent-clones.md) | Agent 分身 | L3 (85%) | 高 |
|
||||||
| [02-hands-system.md](01-core-features/02-hands-system.md) | Hands 系统 | L3 (60%) | 中 |
|
| [02-hands-system.md](01-core-features/02-hands-system.md) | Hands 系统 | L3 (60%) | 中 |
|
||||||
| 工作流引擎 | 工作流引擎 | L3 (80%) | 中 |
|
| 工作流引擎 | 工作流引擎 | L3 (80%) | 中 |
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
| S6 | 导出功能清理 (PPTX/PDF 友好提示) | P2 | ✅ 完成 |
|
| S6 | 导出功能清理 (PPTX/PDF 友好提示) | P2 | ✅ 完成 |
|
||||||
| S7 | Compactor 接入聊天流程 | P1 | ✅ 完成 |
|
| S7 | Compactor 接入聊天流程 | P1 | ✅ 完成 |
|
||||||
| S8 | 定时任务 KernelClient 支持 | P1 | 待开始 |
|
| S8 | 定时任务 KernelClient 支持 | P1 | 待开始 |
|
||||||
| S9 | 添加消息搜索功能 | P1 | 待开始 |
|
| S9 | 添加消息搜索功能 | P1 | ✅ 完成 (Session + Global 双模式) |
|
||||||
| S10 | 优化错误提示 | P1 | 待开始 |
|
| S10 | 优化错误提示 | P1 | 待开始 |
|
||||||
|
|
||||||
### 2.2 中期计划 (1-2 月)
|
### 2.2 中期计划 (1-2 月)
|
||||||
@@ -289,6 +289,7 @@ skills hands protocols pipeline growth channels
|
|||||||
|
|
||||||
| 日期 | 版本 | 变更内容 |
|
| 日期 | 版本 | 变更内容 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
|
| 2026-03-27 | v0.6.4 | **审计修复 (P2 第四轮)**: S9 消息搜索跨会话 (Session + Global 双模式,VikingStorage 搜索)、M5-补 自主授权后端守卫、M3 hand_approve 参数修复、M4-补 反思历史累积存储、心跳历史持久化。累计修复 23 项,整体完成度 65%→72%。|
|
||||||
| 2026-03-27 | v0.6.3 | **审计修复 (P1/P2)**: H3 记忆双存储统一到 VikingStorage、H4 心跳引擎持久化 + 启动恢复、M4 反思结果持久化。整体完成度 58%→62%。|
|
| 2026-03-27 | v0.6.3 | **审计修复 (P1/P2)**: H3 记忆双存储统一到 VikingStorage、H4 心跳引擎持久化 + 启动恢复、M4 反思结果持久化。整体完成度 58%→62%。|
|
||||||
| 2026-03-27 | v0.6.2 | **审计修复 (P0/P1)**: C1 PromptOnly LLM 集成、C2 反思引擎空记忆修复、H7 Agent Store 接口适配、H8 Hand 审批检查、M1 幽灵命令注册、H1/H2 demo 标记、H5 归档过时报告。整体完成度 50%→58%。|
|
| 2026-03-27 | v0.6.2 | **审计修复 (P0/P1)**: C1 PromptOnly LLM 集成、C2 反思引擎空记忆修复、H7 Agent Store 接口适配、H8 Hand 审批检查、M1 幽灵命令注册、H1/H2 demo 标记、H5 归档过时报告。整体完成度 50%→58%。|
|
||||||
| 2026-03-27 | v0.6.1 | **功能完整性修复**: 激活 LoopGuard 循环防护、实现 CapabilityManager.validate() 安全验证、handStore/workflowStore KernelClient 适配器、Credits 标注开发中、Skills 动态化、ScheduledTasks localStorage 降级、token 用量追踪 |
|
| 2026-03-27 | v0.6.1 | **功能完整性修复**: 激活 LoopGuard 循环防护、实现 CapabilityManager.validate() 安全验证、handStore/workflowStore KernelClient 适配器、Credits 标注开发中、Skills 动态化、ScheduledTasks localStorage 降级、token 用量追踪 |
|
||||||
@@ -332,7 +333,18 @@ skills hands protocols pipeline growth channels
|
|||||||
| 反思结果持久化 | M4 | `reflect()` 后持久化 ReflectionState/Result 到 VikingStorage,重启后自动恢复 |
|
| 反思结果持久化 | M4 | `reflect()` 后持久化 ReflectionState/Result 到 VikingStorage,重启后自动恢复 |
|
||||||
| 清理 dead_code warnings | — | PersistentMemoryStore impl 添加 `#[allow(dead_code)]`,移除未使用的 `build_uri` |
|
| 清理 dead_code warnings | — | PersistentMemoryStore impl 添加 `#[allow(dead_code)]`,移除未使用的 `build_uri` |
|
||||||
|
|
||||||
### 7.3 代码清理
|
### 7.3 审计修复 (P2 第四轮)
|
||||||
|
|
||||||
|
| 修复项 | ID | 说明 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| 自主授权后端守卫 | M5-补 | `hand_execute`/`skill_execute` 接收 `autonomy_level` 参数,三级守卫 (supervised/assisted/autonomous) |
|
||||||
|
| hand_approve 参数 | M3 | 移除 `_` 前缀,添加审计日志,返回值包含 hand_name |
|
||||||
|
| 反思历史累积 | M4-补 | 新增 `reflection:history:{agent_id}` 数组(最多 20 条),向后兼容 `reflection:latest` |
|
||||||
|
| 心跳历史持久化 | — | `tick()` 同步存储历史到 VikingStorage,`heartbeat_init()` 恢复历史 |
|
||||||
|
| 身份回滚 UI | — | 确认 `IdentityChangeProposal.tsx` 已实现 HistoryItem + restoreSnapshot |
|
||||||
|
| 跨会话消息搜索 | S9 | MessageSearch 新增 Session/Global 双模式,Global 调用 `memory_search` 搜索 VikingStorage |
|
||||||
|
|
||||||
|
### 7.4 代码清理
|
||||||
|
|
||||||
| 清理项 | 说明 |
|
| 清理项 | 说明 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
@@ -352,4 +364,4 @@ skills hands protocols pipeline growth channels
|
|||||||
| ScheduledTasks 持久化 | 添加 localStorage 降级,刷新不丢失 |
|
| ScheduledTasks 持久化 | 添加 localStorage 降级,刷新不丢失 |
|
||||||
| Token 用量追踪 | chatStore 新增 addTokenUsage/getTotalTokens |
|
| Token 用量追踪 | chatStore 新增 addTokenUsage/getTotalTokens |
|
||||||
|
|
||||||
> **审计说明**: 成熟度等级已根据代码审计调整为实际值。Identity Evolution 标注为 L2 (70%) 是因为其 `dead_code` 属性属于 Tauri 运行时模式(在 Tauri 上下文中实际被调用),而非真正的死代码。Reflection Engine L2 (65%) 因核心反思逻辑尚未深度迭代。
|
> **审计说明**: 成熟度等级已根据代码审计调整为实际值。Identity Evolution 标注为 L2 (70%) 是因为其 `dead_code` 属性属于 Tauri 运行时模式(在 Tauri 上下文中实际被调用),而非真正的死代码。Reflection Engine L2 (65%) 因核心反思逻辑尚未深度迭代。累计修复 23 项后整体完成度从 ~50% 提升到 ~72%。
|
||||||
|
|||||||
Reference in New Issue
Block a user