From 79adf634988a6a35daa2063edb6318d79ef16943 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 24 Feb 2026 20:29:41 +1030 Subject: [PATCH 001/181] chore: remove test_tempfile.rs --- test_tempfile.rs | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 test_tempfile.rs diff --git a/test_tempfile.rs b/test_tempfile.rs deleted file mode 100644 index c66ef48d6..000000000 --- a/test_tempfile.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::io::Write; -use std::path::PathBuf; -use tempfile::NamedTempFile; - -fn main() { - let dir = PathBuf::from("/tmp"); - let target = dir.join("test_target.txt"); - - let content = "Test content"; - - let mut tmp_file = NamedTempFile::new_in(&dir).expect("Failed to create temp file"); - tmp_file.write_all(content.as_bytes()).expect("Failed to write"); - tmp_file.flush().expect("Failed to flush"); - - println!("Temp file created: {:?}", tmp_file.path()); - - tmp_file.persist(&target).expect("Failed to persist"); - - println!("File persisted to: {:?}", target); - - let read_content = std::fs::read_to_string(&target).expect("Failed to read"); - assert_eq!(read_content, content); - - std::fs::remove_file(&target).ok(); - - println!("Test passed!"); -} From 968922481b8d9229c74f5c2a44982b766669dd16 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 24 Feb 2026 21:52:05 +1030 Subject: [PATCH 002/181] feat(openai): add auto summary to reasoning effort parameter --- refact-agent/engine/src/llm/adapters/openai_responses.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/refact-agent/engine/src/llm/adapters/openai_responses.rs b/refact-agent/engine/src/llm/adapters/openai_responses.rs index ffb7a67c7..da73d1e5a 100644 --- a/refact-agent/engine/src/llm/adapters/openai_responses.rs +++ b/refact-agent/engine/src/llm/adapters/openai_responses.rs @@ -137,7 +137,7 @@ impl LlmWireAdapter for OpenAiResponsesAdapter { if settings.supports_reasoning { if let Some(effort) = req.reasoning.to_openai_effort() { - body["reasoning"] = json!({"effort": effort}); + body["reasoning"] = json!({"effort": effort, "summary": "auto"}); } } From 8099a0706b1a2e7528bc78e7f0b02992a243b074 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Sat, 28 Feb 2026 00:20:34 +1030 Subject: [PATCH 003/181] feat(providers): add dynamic model catalog fetching and startup refresh - Implement `/v1/model-catalog` API endpoint for Refact cloud - Add `startup_refresh_and_sync` trait for providers to fetch live model lists and persist config - RefactProvider: fetch running models/pricing from catalog, save to `providers.d/refact.yaml` - ClaudeCode/OpenAICodex: auto-refresh OAuth tokens near expiry, persist to YAML - Update model configs for new Gemini 3.x models and GPT-5.3-codex - Switch providers to `ModelSource::Api`, remove static caps fallback logic - Add YAML config loading for refact provider overrides during registry init --- refact-agent/engine/src/caps/caps.rs | 58 +--- refact-agent/engine/src/global_context.rs | 4 +- .../src/http/routers/v1/code_completion.rs | 4 +- .../engine/src/providers/claude_code.rs | 102 +++++++ refact-agent/engine/src/providers/http.rs | 23 +- .../engine/src/providers/openai_codex.rs | 122 +++++++- refact-agent/engine/src/providers/refact.rs | 287 ++++++++++++++++-- refact-agent/engine/src/providers/registry.rs | 49 ++- refact-agent/engine/src/providers/traits.rs | 14 +- 9 files changed, 575 insertions(+), 88 deletions(-) diff --git a/refact-agent/engine/src/caps/caps.rs b/refact-agent/engine/src/caps/caps.rs index 7469a47c3..911487c52 100644 --- a/refact-agent/engine/src/caps/caps.rs +++ b/refact-agent/engine/src/caps/caps.rs @@ -20,8 +20,7 @@ use crate::caps::model_caps::{ModelCapabilities, get_model_caps, resolve_model_c use crate::llm::WireFormat; use crate::providers::traits::AvailableModel; -pub const CAPS_FILENAME: &str = "refact-caps"; -pub const CAPS_FILENAME_FALLBACK: &str = "coding_assistant_caps.json"; +pub const MODEL_CATALOG_PATH: &str = "v1/model-catalog"; #[derive(Debug, Serialize, Clone, Deserialize, Default, PartialEq)] pub struct BaseModelRecord { @@ -381,19 +380,15 @@ pub async fn load_caps_value_from_url( gcx: Arc>, ) -> Result<(serde_json::Value, String), String> { let caps_urls = if cmdline.address_url.to_lowercase() == "refact" { - vec!["https://inference.smallcloud.ai/coding_assistant_caps.json".to_string()] + vec!["https://inference.smallcloud.ai/v1/model-catalog".to_string()] } else { let base_url = Url::parse(&cmdline.address_url) .map_err(|_| "failed to parse address url".to_string())?; vec![ base_url - .join(&CAPS_FILENAME) - .map_err(|_| "failed to join caps URL".to_string())? - .to_string(), - base_url - .join(&CAPS_FILENAME_FALLBACK) - .map_err(|_| "failed to join fallback caps URL".to_string())? + .join(MODEL_CATALOG_PATH) + .map_err(|_| "failed to join model catalog URL".to_string())? .to_string(), ] }; @@ -446,7 +441,7 @@ pub async fn load_caps_value_from_url( } } - Err(format!("cannot fetch caps, status={}", last_status)) + Err(format!("cannot fetch model catalog, status={}", last_status)) } /// Build ChatModelRecord from an AvailableModel and provider runtime info @@ -961,17 +956,6 @@ pub async fn load_caps( .map_err_with_prefix("Failed to parse caps provider:")?; resolve_relative_urls(&mut server_provider, &caps_url)?; - if caps.cloud_name == "refact" { - server_provider.wire_format = WireFormat::Refact; - server_provider.support_metadata = true; - if let Some(pricing_obj) = caps.metadata.pricing.as_object() { - for model_name in pricing_obj.keys() { - if !server_provider.running_models.contains(model_name) { - server_provider.running_models.push(model_name.clone()); - } - } - } - } info!( "server_provider running_models({})={:?}, completion_endpoint={:?}, completion_default_model={:?}", @@ -988,6 +972,9 @@ pub async fn load_caps( (caps, vec![server_provider]) } Err(e) => { + if is_refact { + return Err(format!("Cloud model catalog fetch failed: {}", e)); + } warn!("Cloud caps fetch failed ({}), falling back to local providers only", e); (CodeAssistantCaps::default(), vec![]) } @@ -1014,22 +1001,6 @@ pub async fn load_caps( } }; caps.model_caps = Arc::new(model_caps_map); - if caps.cloud_name == "refact" { - let running_models = if let Some(pricing_obj) = caps.metadata.pricing.as_object() { - pricing_obj.keys().cloned().collect::>() - } else { - Vec::new() - }; - if !running_models.is_empty() { - let gcx_locked = gcx.write().await; - let mut registry = gcx_locked.providers.write().await; - if let Some(provider) = registry.get_mut("refact") { - provider.set_running_models(running_models); - } - drop(registry); - drop(gcx_locked); - } - } // Clear chat models from legacy CapsProviders that have a new ProviderTrait implementation. // The new system (populate_chat_models_from_providers) is the sole source of truth for @@ -1313,7 +1284,6 @@ fn apply_registry_caps_to_chat_model(record: &mut ChatModelRecord, caps: &ModelC pub fn resolve_completion_model<'a>( caps: Arc, requested_model_id: &str, - try_refact_fallbacks: bool, ) -> Result, String> { let model_id = if !requested_model_id.is_empty() { requested_model_id @@ -1321,17 +1291,7 @@ pub fn resolve_completion_model<'a>( &caps.defaults.completion_default_model }; - match resolve_model(&caps.completion_models, model_id) { - Ok(model) => Ok(model), - Err(first_err) if try_refact_fallbacks => { - if let Ok(model) = resolve_model(&caps.completion_models, &format!("refact/{model_id}")) - { - return Ok(model); - } - Err(first_err) - } - Err(err) => Err(err), - } + resolve_model(&caps.completion_models, model_id) } #[allow(dead_code)] diff --git a/refact-agent/engine/src/global_context.rs b/refact-agent/engine/src/global_context.rs index 30f75faf3..f8e7ae3dd 100644 --- a/refact-agent/engine/src/global_context.rs +++ b/refact-agent/engine/src/global_context.rs @@ -559,7 +559,7 @@ pub async fn create_global_context( let cx = GlobalContext { shutdown_flag: Arc::new(AtomicBool::new(false)), cmdline: cmdline.clone(), - http_client, + http_client: http_client.clone(), http_client_slowdown: Arc::new(Semaphore::new(2)), cache_dir, config_dir: config_dir.clone(), @@ -597,7 +597,7 @@ pub async fn create_global_context( voice_service: crate::voice::VoiceService::new(), project_registry_cache: Arc::new(StdRwLock::new(RegistryCacheManager::new())), providers: Arc::new(ARwLock::new( - load_providers_from_config(&config_dir, &cmdline.address_url, &cmdline.api_key) + load_providers_from_config(&config_dir, &cmdline.address_url, &cmdline.api_key, &http_client) .await .unwrap_or_default() )), diff --git a/refact-agent/engine/src/http/routers/v1/code_completion.rs b/refact-agent/engine/src/http/routers/v1/code_completion.rs index 84a06c728..0fed96c96 100644 --- a/refact-agent/engine/src/http/routers/v1/code_completion.rs +++ b/refact-agent/engine/src/http/routers/v1/code_completion.rs @@ -33,7 +33,7 @@ pub async fn handle_v1_code_completion( .map_err(|e| ScratchError::new(StatusCode::UNPROCESSABLE_ENTITY, e))?; let caps = crate::global_context::try_load_caps_quickly_if_not_present(gcx.clone(), 0).await?; - let model_rec = resolve_completion_model(caps, &code_completion_post.model, true) + let model_rec = resolve_completion_model(caps, &code_completion_post.model) .map_err(|e| ScratchError::new(StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?; if code_completion_post.parameters.max_new_tokens == 0 { code_completion_post.parameters.max_new_tokens = 50; @@ -143,7 +143,7 @@ pub async fn handle_v1_code_completion_prompt( .map_err(|e| ScratchError::new(StatusCode::UNPROCESSABLE_ENTITY, e))?; let caps = crate::global_context::try_load_caps_quickly_if_not_present(gcx.clone(), 0).await?; - let model_rec = resolve_completion_model(caps, &post.model, true) + let model_rec = resolve_completion_model(caps, &post.model) .map_err(|e| ScratchError::new(StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?; // don't need cache, but go along diff --git a/refact-agent/engine/src/providers/claude_code.rs b/refact-agent/engine/src/providers/claude_code.rs index fd2afab11..c2fc0b8b0 100644 --- a/refact-agent/engine/src/providers/claude_code.rs +++ b/refact-agent/engine/src/providers/claude_code.rs @@ -1,5 +1,6 @@ use std::any::Any; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -45,6 +46,78 @@ pub struct ClaudeCodeProvider { } impl ClaudeCodeProvider { + fn needs_refresh_on_start(expires_at: i64) -> bool { + const REFRESH_BEFORE_EXPIRY_MS: i64 = 5 * 60 * 1000; + if expires_at == 0 { + return true; + } + let now_ms = chrono::Utc::now().timestamp_millis(); + now_ms >= expires_at - REFRESH_BEFORE_EXPIRY_MS + } + + async fn save_oauth_tokens_config(&self, config_dir: &std::path::Path) -> Result<(), String> { + let providers_dir = config_dir.join("providers.d"); + let config_path = providers_dir.join("claude_code.yaml"); + + tokio::fs::create_dir_all(&providers_dir) + .await + .map_err(|e| format!("Failed to create providers.d: {}", e))?; + + let mut yaml_map: serde_yaml::Mapping = if config_path.exists() { + let content = tokio::fs::read_to_string(&config_path) + .await + .map_err(|e| format!("Failed to read config: {}", e))?; + let value: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|e| format!("Failed to parse YAML: {}", e))?; + value + .as_mapping() + .cloned() + .ok_or_else(|| "Config file root is not a YAML mapping. Cannot safely patch.".to_string())? + } else { + serde_yaml::Mapping::new() + }; + + let mut tokens_map = yaml_map + .get(&serde_yaml::Value::String("oauth_tokens".to_string())) + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + + tokens_map.insert( + serde_yaml::Value::String("access_token".to_string()), + serde_yaml::Value::String(self.oauth_tokens.access_token.clone()), + ); + tokens_map.insert( + serde_yaml::Value::String("refresh_token".to_string()), + serde_yaml::Value::String(self.oauth_tokens.refresh_token.clone()), + ); + tokens_map.insert( + serde_yaml::Value::String("expires_at".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(self.oauth_tokens.expires_at)), + ); + + yaml_map.insert( + serde_yaml::Value::String("oauth_tokens".to_string()), + serde_yaml::Value::Mapping(tokens_map), + ); + + let content = serde_yaml::to_string(&yaml_map) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique_id = COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_path = config_path.with_extension(format!("yaml.tmp.oauth.{}.{}", std::process::id(), unique_id)); + + tokio::fs::write(&temp_path, &content) + .await + .map_err(|e| format!("Failed to write temp config: {}", e))?; + tokio::fs::rename(&temp_path, &config_path) + .await + .map_err(|e| format!("Failed to rename config: {}", e))?; + + Ok(()) + } + fn detect_cli_path(&self) -> Option { if let Some(ref p) = self.cli_path { if std::path::Path::new(p).exists() { @@ -415,6 +488,35 @@ available: fn remove_custom_model(&mut self, model_id: &str) -> bool { self.custom_models.remove(model_id).is_some() } + + async fn startup_refresh_and_sync( + &mut self, + http_client: &reqwest::Client, + config_dir: &std::path::Path, + ) -> Result<(), String> { + if self.oauth_tokens.is_empty() || self.oauth_tokens.refresh_token.is_empty() { + return Ok(()); + } + + if !Self::needs_refresh_on_start(self.oauth_tokens.expires_at) { + return Ok(()); + } + + tracing::info!("Claude Code: refreshing OAuth token on startup"); + let refreshed = crate::providers::claude_code_oauth::refresh_access_token( + http_client, + &self.oauth_tokens.refresh_token, + ) + .await?; + + self.oauth_tokens.access_token = refreshed.access_token; + if !refreshed.refresh_token.is_empty() { + self.oauth_tokens.refresh_token = refreshed.refresh_token; + } + self.oauth_tokens.expires_at = refreshed.expires_at; + + self.save_oauth_tokens_config(config_dir).await + } } const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models"; diff --git a/refact-agent/engine/src/providers/http.rs b/refact-agent/engine/src/providers/http.rs index 0a7a2a254..26810dfad 100644 --- a/refact-agent/engine/src/providers/http.rs +++ b/refact-agent/engine/src/providers/http.rs @@ -877,8 +877,6 @@ async fn update_model_enabled_state( return Err(e); } - // Reload provider from disk to ensure the enabled flag is applied in-memory. - // (enabled is stored in YAML and used by build_runtime for caps population) reload_provider_from_disk(gcx.clone(), provider_name, &config_dir).await?; invalidate_caps(gcx).await; @@ -1324,16 +1322,21 @@ async fn reload_provider_from_disk( let yaml: serde_yaml::Value = serde_yaml::from_str(&content) .map_err(|e| ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid YAML after save: {}", e)))?; - let mut provider = create_provider(provider_name) - .ok_or_else(|| ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create provider".to_string()))?; - - provider - .provider_settings_apply(yaml) - .map_err(|e| ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to apply settings: {}", e)))?; - let gcx_locked = gcx.read().await; let mut registry = gcx_locked.providers.write().await; - registry.add(provider); + + if let Some(existing) = registry.get_mut(provider_name) { + existing + .provider_settings_apply(yaml) + .map_err(|e| ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to apply settings: {}", e)))?; + } else { + let mut provider = create_provider(provider_name) + .ok_or_else(|| ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, "Failed to create provider".to_string()))?; + provider + .provider_settings_apply(yaml) + .map_err(|e| ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to apply settings: {}", e)))?; + registry.add(provider); + } Ok(()) } diff --git a/refact-agent/engine/src/providers/openai_codex.rs b/refact-agent/engine/src/providers/openai_codex.rs index 81b8821e6..fbe54ed2b 100644 --- a/refact-agent/engine/src/providers/openai_codex.rs +++ b/refact-agent/engine/src/providers/openai_codex.rs @@ -1,5 +1,6 @@ use std::any::Any; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -12,7 +13,6 @@ use crate::providers::traits::{ AvailableModel, CustomModelConfig, ModelPricing, ModelSource, ProviderRuntime, ProviderTrait, merge_custom_models, parse_enabled_models, parse_custom_models, set_model_enabled_impl, }; -use crate::providers::pricing::openai_pricing; #[derive(Debug, Clone, Copy, PartialEq)] enum AuthSource { @@ -42,6 +42,90 @@ pub struct OpenAICodexProvider { } impl OpenAICodexProvider { + fn needs_refresh_on_start(expires_at: i64) -> bool { + const REFRESH_BEFORE_EXPIRY_MS: i64 = 5 * 60 * 1000; + if expires_at == 0 { + return true; + } + let now_ms = chrono::Utc::now().timestamp_millis(); + now_ms >= expires_at - REFRESH_BEFORE_EXPIRY_MS + } + + async fn save_oauth_tokens_config(&self, config_dir: &std::path::Path) -> Result<(), String> { + let providers_dir = config_dir.join("providers.d"); + let config_path = providers_dir.join("openai_codex.yaml"); + + tokio::fs::create_dir_all(&providers_dir) + .await + .map_err(|e| format!("Failed to create providers.d: {}", e))?; + + let mut yaml_map: serde_yaml::Mapping = if config_path.exists() { + let content = tokio::fs::read_to_string(&config_path) + .await + .map_err(|e| format!("Failed to read config: {}", e))?; + let value: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|e| format!("Failed to parse YAML: {}", e))?; + value + .as_mapping() + .cloned() + .ok_or_else(|| "Config file root is not a YAML mapping. Cannot safely patch.".to_string())? + } else { + serde_yaml::Mapping::new() + }; + + let mut tokens_map = yaml_map + .get(&serde_yaml::Value::String("oauth_tokens".to_string())) + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + + tokens_map.insert( + serde_yaml::Value::String("access_token".to_string()), + serde_yaml::Value::String(self.oauth_tokens.access_token.clone()), + ); + tokens_map.insert( + serde_yaml::Value::String("refresh_token".to_string()), + serde_yaml::Value::String(self.oauth_tokens.refresh_token.clone()), + ); + tokens_map.insert( + serde_yaml::Value::String("expires_at".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(self.oauth_tokens.expires_at)), + ); + tokens_map.insert( + serde_yaml::Value::String("openai_api_key".to_string()), + serde_yaml::Value::String(self.oauth_tokens.openai_api_key.clone()), + ); + tokens_map.insert( + serde_yaml::Value::String("chatgpt_account_id".to_string()), + serde_yaml::Value::String(self.oauth_tokens.chatgpt_account_id.clone()), + ); + tokens_map.insert( + serde_yaml::Value::String("api_key_exchange_error".to_string()), + serde_yaml::Value::String(self.oauth_tokens.api_key_exchange_error.clone()), + ); + + yaml_map.insert( + serde_yaml::Value::String("oauth_tokens".to_string()), + serde_yaml::Value::Mapping(tokens_map), + ); + + let content = serde_yaml::to_string(&yaml_map) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique_id = COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_path = config_path.with_extension(format!("yaml.tmp.oauth.{}.{}", std::process::id(), unique_id)); + + tokio::fs::write(&temp_path, &content) + .await + .map_err(|e| format!("Failed to write temp config: {}", e))?; + tokio::fs::rename(&temp_path, &config_path) + .await + .map_err(|e| format!("Failed to rename config: {}", e))?; + + Ok(()) + } + /// Returns the credential to use for api.openai.com endpoints. /// /// IMPORTANT: Codex/ChatGPT OAuth produces an OAuth access token, but the OpenAI Platform @@ -358,6 +442,40 @@ available: return config.pricing.clone(); } } - openai_pricing(model_id) + None + } + + async fn startup_refresh_and_sync( + &mut self, + http_client: &reqwest::Client, + config_dir: &std::path::Path, + ) -> Result<(), String> { + if self.oauth_tokens.is_empty() || self.oauth_tokens.refresh_token.is_empty() { + return Ok(()); + } + + if !Self::needs_refresh_on_start(self.oauth_tokens.expires_at) { + return Ok(()); + } + + tracing::info!("OpenAI Codex: refreshing OAuth token on startup"); + let mut refreshed = crate::providers::openai_codex_oauth::refresh_access_token( + http_client, + &self.oauth_tokens.refresh_token, + ) + .await?; + + if refreshed.openai_api_key.is_empty() { + refreshed.openai_api_key = self.oauth_tokens.openai_api_key.clone(); + } + if refreshed.chatgpt_account_id.is_empty() { + refreshed.chatgpt_account_id = self.oauth_tokens.chatgpt_account_id.clone(); + } + if refreshed.api_key_exchange_error.is_empty() { + refreshed.api_key_exchange_error = self.oauth_tokens.api_key_exchange_error.clone(); + } + + self.oauth_tokens = refreshed; + self.save_oauth_tokens_config(config_dir).await } } diff --git a/refact-agent/engine/src/providers/refact.rs b/refact-agent/engine/src/providers/refact.rs index 5775e261f..5187d2928 100644 --- a/refact-agent/engine/src/providers/refact.rs +++ b/refact-agent/engine/src/providers/refact.rs @@ -4,11 +4,12 @@ use std::collections::HashMap; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::caps::model_caps::{ModelCapabilities, resolve_model_caps}; use crate::llm::adapter::WireFormat; use crate::providers::config::resolve_env_var; -use crate::providers::traits::{AvailableModel, ModelSource, ProviderRuntime, ProviderTrait}; +use crate::providers::traits::{AvailableModel, ModelPricing, ModelSource, ProviderRuntime, ProviderTrait}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RefactProvider { @@ -22,6 +23,41 @@ pub struct RefactProvider { } impl RefactProvider { + fn config_path(config_dir: &std::path::Path) -> std::path::PathBuf { + config_dir.join("providers.d").join("refact.yaml") + } + + async fn save_config(&self, config_dir: &std::path::Path) -> Result<(), String> { + let providers_dir = config_dir.join("providers.d"); + tokio::fs::create_dir_all(&providers_dir) + .await + .map_err(|e| format!("Failed to create providers.d: {}", e))?; + + let config_path = Self::config_path(config_dir); + let payload = serde_yaml::to_string(&serde_yaml::to_value(json!({ + "enabled": self.enabled, + "disabled_models": self.disabled_models, + "running_models": self.running_models, + })) + .map_err(|e| format!("Failed to serialize refact provider settings: {}", e))?) + .map_err(|e| format!("Failed to render refact provider yaml: {}", e))?; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let temp_path = config_path.with_extension(format!( + "yaml.tmp.{}.{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + + tokio::fs::write(&temp_path, payload) + .await + .map_err(|e| format!("Failed to write temporary refact config: {}", e))?; + tokio::fs::rename(&temp_path, &config_path) + .await + .map_err(|e| format!("Failed to finalize refact config: {}", e))?; + Ok(()) + } + pub fn from_cli(address_url: String, api_key: String) -> Self { Self { address_url, @@ -31,6 +67,193 @@ impl RefactProvider { running_models: Vec::new(), } } + + fn base_url(&self) -> String { + if self.address_url.is_empty() || self.address_url.to_lowercase() == "refact" { + "https://inference.smallcloud.ai".to_string() + } else { + self.address_url.trim_end_matches('/').to_string() + } + } + + fn model_catalog_url(&self) -> String { + format!("{}/v1/model-catalog", self.base_url()) + } + + fn parse_model_pricing_from_json(value: &serde_json::Value) -> Option { + let prompt = value.get("prompt").and_then(|v| v.as_f64())?; + let generated = value.get("generated").and_then(|v| v.as_f64())?; + let pricing = ModelPricing { + prompt, + generated, + cache_read: value.get("cache_read").and_then(|v| v.as_f64()), + cache_creation: value.get("cache_creation").and_then(|v| v.as_f64()), + }; + if pricing.is_valid() { + Some(pricing) + } else { + None + } + } + + fn model_is_disabled(&self, model_id: &str) -> bool { + self.disabled_models.contains(&model_id.to_string()) + || self.disabled_models.contains(&format!("refact/{}", model_id)) + } + + pub fn extract_chat_model_ids_from_catalog(catalog: &serde_json::Value) -> Vec { + let mut ids: Vec = catalog + .get("chat") + .and_then(|v| v.get("models")) + .and_then(|v| v.as_object()) + .map(|models| models.keys().cloned().collect()) + .unwrap_or_default(); + ids.sort(); + ids + } + + pub async fn fetch_model_catalog( + &self, + http_client: &reqwest::Client, + ) -> Result { + let mut request = http_client + .get(self.model_catalog_url()) + .header( + reqwest::header::USER_AGENT, + format!("refact-lsp {}", crate::version::build::PKG_VERSION), + ); + + let api_key = resolve_env_var(&self.api_key, "", "refact api_key"); + if !api_key.is_empty() { + request = request.bearer_auth(api_key); + } + + let response = request + .send() + .await + .map_err(|e| format!("Failed to fetch Refact model catalog: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_else(|_| String::new()); + return Err(format!( + "Refact model catalog fetch failed: HTTP {} {}", + status, body + )); + } + + let payload: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Invalid Refact model catalog JSON: {}", e))?; + + let cloud_name = payload + .get("cloud_name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_lowercase(); + if cloud_name != "refact" { + return Err("Model catalog response is not a Refact catalog".to_string()); + } + + Ok(payload) + } + + pub async fn sync_running_models_from_catalog( + &mut self, + http_client: &reqwest::Client, + ) -> Result<(), String> { + let catalog = self.fetch_model_catalog(http_client).await?; + let catalog_models = Self::extract_chat_model_ids_from_catalog(&catalog); + + let mut disabled: std::collections::HashSet = + self.disabled_models.iter().cloned().collect(); + disabled.retain(|m| { + let bare = m.strip_prefix("refact/").unwrap_or(m); + catalog_models.iter().any(|x| x == bare) + }); + + self.running_models = catalog_models; + self.disabled_models = disabled.into_iter().collect(); + self.disabled_models.sort(); + Ok(()) + } + + fn extract_available_models_from_catalog( + &self, + catalog: &serde_json::Value, + ) -> Result, String> { + let chat_models = catalog + .get("chat") + .and_then(|v| v.get("models")) + .and_then(|v| v.as_object()) + .ok_or_else(|| "Model catalog response missing chat.models".to_string())?; + + let pricing_map = catalog + .get("metadata") + .and_then(|v| v.get("pricing")) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let tokenizer_endpoints = catalog + .get("tokenizer_endpoints") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let mut models: Vec = Vec::new(); + for (model_id, model_info) in chat_models { + let n_ctx = model_info + .get("n_ctx") + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .unwrap_or(4096); + let supports_tools = model_info + .get("supports_tools") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let supports_multimodality = model_info + .get("supports_multimodality") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let max_output_tokens = model_info + .get("max_output_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + + let tokenizer = tokenizer_endpoints + .get(model_id) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let pricing = pricing_map + .get(model_id) + .and_then(Self::parse_model_pricing_from_json); + + models.push(AvailableModel { + id: model_id.clone(), + display_name: None, + n_ctx, + supports_tools, + supports_multimodality, + reasoning_effort_options: None, + supports_thinking_budget: false, + supports_adaptive_thinking_budget: false, + tokenizer, + enabled: !self.model_is_disabled(model_id), + is_custom: false, + pricing, + available_providers: Vec::new(), + selected_provider: None, + max_output_tokens, + provider_variants: Vec::new(), + }); + } + + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) + } } #[async_trait] @@ -89,6 +312,14 @@ available: self.enabled = enabled; } crate::providers::traits::parse_disabled_models(&yaml, &mut self.disabled_models); + if let Some(models) = yaml.get("running_models").and_then(|v| v.as_sequence()) { + self.running_models = models + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + self.running_models.sort(); + self.running_models.dedup(); + } Ok(()) } @@ -97,24 +328,19 @@ available: "address_url": self.address_url, "api_key": if self.api_key.is_empty() { "" } else { "***" }, "enabled": self.enabled, - "disabled_models": self.disabled_models + "disabled_models": self.disabled_models, + "running_models": self.running_models }) } fn build_runtime(&self) -> Result { let api_key = resolve_env_var(&self.api_key, "", "refact api_key"); - let base_url = if self.address_url.is_empty() - || self.address_url.to_lowercase() == "refact" - { - "https://inference.smallcloud.ai".to_string() - } else { - self.address_url.trim_end_matches('/').to_string() - }; + let base_url = self.base_url(); Ok(ProviderRuntime { name: self.name().to_string(), display_name: self.display_name().to_string(), - enabled: self.enabled && !self.running_models.is_empty() && !api_key.is_empty(), + enabled: self.enabled && !api_key.is_empty(), readonly: false, wire_format: self.default_wire_format(), chat_endpoint: format!("{}/v1/chat/completions", base_url), @@ -141,7 +367,7 @@ available: } fn model_source(&self) -> ModelSource { - ModelSource::ModelCaps + ModelSource::Api } fn selected_model_count(&self) -> usize { @@ -149,7 +375,7 @@ available: return 0; } self.running_models.iter() - .filter(|m| !self.disabled_models.contains(m)) + .filter(|m| !self.model_is_disabled(m)) .count() } @@ -161,10 +387,6 @@ available: crate::providers::traits::set_model_disabled_impl(&mut self.disabled_models, model_id, enabled); } - fn set_running_models(&mut self, running_models: Vec) { - self.running_models = running_models; - } - fn get_available_models_from_caps( &self, model_caps: &HashMap, @@ -177,7 +399,7 @@ available: for running_model in &self.running_models { if let Some(resolved) = resolve_model_caps(model_caps, running_model) { - let disabled = self.disabled_models.contains(running_model); + let disabled = self.model_is_disabled(running_model); let pricing = self.model_pricing(running_model); let mut model = AvailableModel::from_caps(running_model, &resolved.caps, !disabled, pricing); if running_model != &resolved.matched_key { @@ -189,7 +411,7 @@ available: "Refact running model '{}' not found in model capabilities, adding with defaults", running_model ); - let disabled = self.disabled_models.contains(running_model); + let disabled = self.model_is_disabled(running_model); models.push(AvailableModel { id: running_model.clone(), display_name: None, @@ -214,5 +436,34 @@ available: models.sort_by(|a, b| a.id.cmp(&b.id)); models } + + async fn fetch_available_models( + &self, + http_client: &reqwest::Client, + _model_caps: &HashMap, + ) -> Vec { + match self.fetch_model_catalog(http_client).await { + Ok(catalog) => match self.extract_available_models_from_catalog(&catalog) { + Ok(models) => models, + Err(e) => { + tracing::warn!("Refact model catalog parse failed: {}", e); + Vec::new() + } + }, + Err(e) => { + tracing::warn!("Refact model catalog fetch failed: {}", e); + Vec::new() + } + } + } + + async fn startup_refresh_and_sync( + &mut self, + http_client: &reqwest::Client, + config_dir: &std::path::Path, + ) -> Result<(), String> { + self.sync_running_models_from_catalog(http_client).await?; + self.save_config(config_dir).await + } } diff --git a/refact-agent/engine/src/providers/registry.rs b/refact-agent/engine/src/providers/registry.rs index 349924d90..f5652df55 100644 --- a/refact-agent/engine/src/providers/registry.rs +++ b/refact-agent/engine/src/providers/registry.rs @@ -1,5 +1,7 @@ use std::path::Path; +use serde_yaml::Value; + use crate::providers::traits::ProviderTrait; use crate::providers::{ refact::RefactProvider, @@ -101,6 +103,7 @@ pub async fn load_providers_from_config( config_dir: &Path, refact_address_url: &str, refact_api_key: &str, + http_client: &reqwest::Client, ) -> Result { let mut registry = ProviderRegistry::new(); @@ -133,7 +136,41 @@ pub async fn load_providers_from_config( Some(n) => n, None => continue, }; - if name == "defaults" || name == "refact" { + if name == "defaults" { + continue; + } + + if name == "refact" { + let content = match tokio::fs::read_to_string(&path).await { + Ok(c) => c, + Err(e) => { + tracing::warn!("Failed to read provider config {}: {}", path.display(), e); + continue; + } + }; + + let mut yaml: Value = match serde_yaml::from_str(&content) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to parse provider config {}: {}", path.display(), e); + continue; + } + }; + + if let Some(map) = yaml.as_mapping_mut() { + map.remove(Value::String("api_key".to_string())); + map.remove(Value::String("address_url".to_string())); + } + + if let Some(provider) = registry.get_mut("refact") { + if let Err(e) = provider.provider_settings_apply(yaml) { + tracing::warn!( + "Failed to apply provider config {} to refact provider: {}", + path.display(), + e + ); + } + } continue; } @@ -166,6 +203,16 @@ pub async fn load_providers_from_config( registry.add(provider); } + for provider in registry.providers.iter_mut() { + if let Err(e) = provider.startup_refresh_and_sync(http_client, config_dir).await { + tracing::warn!( + "Provider '{}' startup refresh failed: {}", + provider.name(), + e + ); + } + } + Ok(registry) } diff --git a/refact-agent/engine/src/providers/traits.rs b/refact-agent/engine/src/providers/traits.rs index fd0ef6196..43fb58b13 100644 --- a/refact-agent/engine/src/providers/traits.rs +++ b/refact-agent/engine/src/providers/traits.rs @@ -340,10 +340,6 @@ pub trait ProviderTrait: Send + Sync { None } - fn set_running_models(&mut self, _running_models: Vec) { - // Default: no-op, providers that need running_models filtering override this - } - /// Discover and return available models for this provider. /// Providers that need network access (API fetching) override this async method. /// Default implementation matches against model_caps using the provider's filter regex @@ -357,6 +353,16 @@ pub trait ProviderTrait: Send + Sync { self.get_available_models_from_caps(model_caps) } + /// Optional startup hook for providers that need to refresh dynamic state + /// (for example, model catalogs) and persist provider-local config. + async fn startup_refresh_and_sync( + &mut self, + _http_client: &reqwest::Client, + _config_dir: &std::path::Path, + ) -> Result<(), String> { + Ok(()) + } + fn get_available_models_from_caps( &self, model_caps: &HashMap, From 072de407494f1dff51fc64464797f3779ed41a08 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Sat, 28 Feb 2026 01:52:38 +1030 Subject: [PATCH 004/181] feat(chat)!: add queued message editing and priority toggle feat(markdown): add Mermaid and SVG diagram rendering with toggle/copy UI fix(sse): distinguish buffered vs total bytes for accurate flush caps fix(sse): fix lagged recovery seq monotonicity with re-subscribe-before-snapshot feat(queued): add full content to QueuedItem for editing workflows perf(sse): improve stream delta merging with precise pending byte tracking fix(cef): improve JCEF render process crash recovery and health checks refactor(chat): add closed_flag atomic for efficient SSE heartbeat checks style(chat): make queued messages editable with click/keyboard support chore(deps): add mermaid, dompurify for diagram support --- refact-agent/engine/src/caps/caps.rs | 2 +- refact-agent/engine/src/chat/handlers.rs | 38 +- refact-agent/engine/src/chat/session.rs | 91 +- refact-agent/engine/src/chat/tests.rs | 1 + refact-agent/engine/src/chat/trajectories.rs | 10 +- refact-agent/engine/src/chat/types.rs | 81 +- refact-agent/gui/package-lock.json | 1065 ++++++++++++++++- refact-agent/gui/package.json | 3 + .../ChatContent/ChatContent.module.css | 8 + .../components/ChatContent/QueuedMessage.tsx | 214 +++- .../src/components/ChatForm/useInputValue.ts | 9 + .../Markdown/DiagramBlock.module.css | 56 + .../src/components/Markdown/MermaidBlock.tsx | 145 +++ .../components/Markdown/ShikiCodeBlock.tsx | 14 + .../gui/src/components/Markdown/SvgBlock.tsx | 194 +++ .../gui/src/features/Chat/Thread/types.ts | 1 + .../gui/src/hooks/useAllChatsSubscription.ts | 72 +- .../gui/src/hooks/useChatSubscription.ts | 29 +- 18 files changed, 1898 insertions(+), 135 deletions(-) create mode 100644 refact-agent/gui/src/components/Markdown/DiagramBlock.module.css create mode 100644 refact-agent/gui/src/components/Markdown/MermaidBlock.tsx create mode 100644 refact-agent/gui/src/components/Markdown/SvgBlock.tsx diff --git a/refact-agent/engine/src/caps/caps.rs b/refact-agent/engine/src/caps/caps.rs index 911487c52..bdb2d35ae 100644 --- a/refact-agent/engine/src/caps/caps.rs +++ b/refact-agent/engine/src/caps/caps.rs @@ -532,7 +532,7 @@ fn build_chat_model_record( } else { ( model.n_ctx, - model.supports_tools, + false, model.supports_multimodality, model.reasoning_effort_options.clone(), model.supports_thinking_budget, diff --git a/refact-agent/engine/src/chat/handlers.rs b/refact-agent/engine/src/chat/handlers.rs index 3c595d83f..c5b4b4513 100644 --- a/refact-agent/engine/src/chat/handlers.rs +++ b/refact-agent/engine/src/chat/handlers.rs @@ -46,12 +46,20 @@ pub async fn handle_v1_chat_subscribe( event: snapshot, }; + let initial_json = match serde_json::to_string(&initial_envelope) { + Ok(j) => j, + Err(e) => { + tracing::error!("Failed to serialize initial SSE snapshot for {}: {}", chat_id, e); + return Err(ScratchError::new(StatusCode::INTERNAL_SERVER_ERROR, "snapshot serialization failed".to_string())); + } + }; + let session_for_stream = session_arc.clone(); let chat_id_for_stream = chat_id.clone(); + let closed_flag = session_arc.lock().await.closed_flag.clone(); let stream = async_stream::stream! { - let json = serde_json::to_string(&initial_envelope).unwrap_or_default(); - yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)); + yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", initial_json)); let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(15)); heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -61,26 +69,42 @@ pub async fn handle_v1_chat_subscribe( result = rx.recv() => { match result { Ok(envelope) => { - let json = serde_json::to_string(&envelope).unwrap_or_default(); - yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)); + match serde_json::to_string(&envelope) { + Ok(json) => yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)), + Err(e) => { + tracing::error!("Failed to serialize SSE event for {}: {}", chat_id_for_stream, e); + break; + } + } } Err(broadcast::error::RecvError::Lagged(skipped)) => { tracing::info!("SSE subscriber lagged, skipped {} events, sending fresh snapshot", skipped); let session = session_for_stream.lock().await; + if session.closed { + break; + } + // Re-subscribe BEFORE capturing event_seq so we don't miss events + // emitted between snapshot and the new receiver start. + rx = session.subscribe(); let recovery_envelope = EventEnvelope { chat_id: chat_id_for_stream.clone(), seq: session.event_seq, event: session.snapshot(), }; drop(session); - let json = serde_json::to_string(&recovery_envelope).unwrap_or_default(); - yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)); + match serde_json::to_string(&recovery_envelope) { + Ok(json) => yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)), + Err(e) => { + tracing::error!("Failed to serialize SSE recovery snapshot for {}: {}", chat_id_for_stream, e); + break; + } + } } Err(broadcast::error::RecvError::Closed) => break, } } _ = heartbeat_interval.tick() => { - if session_for_stream.lock().await.closed { + if closed_flag.load(std::sync::atomic::Ordering::Relaxed) { break; } yield Ok::<_, std::convert::Infallible>(format!(": hb {}\n\n", chrono::Utc::now().timestamp())); diff --git a/refact-agent/engine/src/chat/session.rs b/refact-agent/engine/src/chat/session.rs index e1efb5ebb..af7db15d3 100644 --- a/refact-agent/engine/src/chat/session.rs +++ b/refact-agent/engine/src/chat/session.rs @@ -47,6 +47,7 @@ impl ChatSession { trajectory_version: 0, created_at: chrono::Utc::now().to_rfc3339(), closed: false, + closed_flag: Arc::new(AtomicBool::new(false)), external_reload_pending: false, last_prompt_messages: Vec::new(), cache_guard_snapshot: None, @@ -84,6 +85,7 @@ impl ChatSession { trajectory_version: 0, created_at, closed: false, + closed_flag: Arc::new(AtomicBool::new(false)), last_prompt_messages: Vec::new(), cache_guard_snapshot: None, cache_guard_force_next: false, @@ -112,6 +114,8 @@ impl ChatSession { } pub fn close_event_channel(&mut self) { + self.closed = true; + self.closed_flag.store(true, Ordering::Relaxed); let (new_tx, _) = broadcast::channel(limits().event_channel_capacity); self.event_tx = new_tx; } @@ -724,9 +728,8 @@ pub async fn close_all_chat_sessions(gcx: Arc>) { ).await; match lock_result { Ok(mut session) => { - session.closed = true; session.abort_stream(); - session.close_event_channel(); + session.close_event_channel(); // sets closed + closed_flag session.queue_notify.notify_waiters(); } Err(_) => { @@ -776,8 +779,7 @@ pub fn start_session_cleanup_task(gcx: Arc>) { for (chat_id, session_arc) in &to_cleanup { { let mut session = session_arc.lock().await; - session.closed = true; - session.close_event_channel(); + session.close_event_channel(); // sets closed + closed_flag session.queue_notify.notify_waiters(); } { @@ -806,6 +808,16 @@ mod tests { ChatSession::new("test-chat".to_string()) } + /// Creates a session with a small broadcast channel capacity, useful for + /// triggering `RecvError::Lagged` quickly in tests without emitting + /// thousands of events. + fn make_session_with_capacity(capacity: usize) -> ChatSession { + let (event_tx, _) = broadcast::channel(capacity); + let mut session = ChatSession::new("test-chat-small".to_string()); + session.event_tx = event_tx; + session + } + #[test] fn test_new_session_initial_state() { let session = make_session(); @@ -1478,6 +1490,77 @@ mod tests { "Truly empty assistant message should be discarded"); } + /// Regression test: after a broadcast::Receiver lags, the handler must + /// re-subscribe (`rx = session.subscribe()`) before capturing `event_seq` + /// for the recovery snapshot. Without re-subscribing, the old receiver + /// resumes from the oldest ring-buffer entry whose seq is *lower* than the + /// snapshot seq, causing the frontend to silently drop every subsequent event. + /// + /// This test simulates the handler's Lagged recovery path and asserts that + /// the first event received after the snapshot has seq == snapshot_seq + 1. + #[tokio::test] + async fn test_lagged_recovery_seq_monotonicity() { + use tokio::sync::broadcast::error::RecvError; + + // Use a tiny channel capacity so we only need to emit a handful of + // events to trigger Lagged rather than the default 4096+. + const SMALL_CAP: usize = 8; + let mut session = make_session_with_capacity(SMALL_CAP); + + // Subscribe a "slow" receiver that we will intentionally lag. + let mut slow_rx = session.subscribe(); + + // Emit capacity+1 events so slow_rx is guaranteed to lag. + let overflow_count = SMALL_CAP + 1; + for _ in 0..overflow_count { + session.emit(ChatEvent::QueueUpdated { + queue_size: 0, + queued_items: vec![], + }); + } + + // Confirm that slow_rx is lagged. + assert!( + matches!(slow_rx.recv().await, Err(RecvError::Lagged(_))), + "slow_rx should be lagged after overflow" + ); + + // --- Simulate the handler's recovery path --- + // After Lagged, the handler must: + // 1. Lock the session + // 2. Re-subscribe to get a fresh receiver + // 3. Capture event_seq for the recovery snapshot + // 4. Drop the lock + // 5. Emit one more event (from some background task) + // 6. Assert first recv() on fresh_rx has seq == snapshot_seq + 1 + + // Step 2-3: re-subscribe while holding the "lock" (single-threaded here). + let mut fresh_rx = session.subscribe(); + let snapshot_seq = session.event_seq; + + // Step 5: emit one more event (e.g. a RuntimeUpdated broadcast). + session.emit(ChatEvent::QueueUpdated { + queue_size: 0, + queued_items: vec![], + }); + + // Step 6: the first event from fresh_rx must have seq == snapshot_seq + 1. + let envelope = fresh_rx + .recv() + .await + .expect("fresh_rx should receive an event"); + + assert_eq!( + envelope.seq, + snapshot_seq + 1, + "First event after re-subscribe must have seq == snapshot_seq + 1, \ + got {} (snapshot_seq={}). \ + If seq < snapshot_seq the frontend drops all events forever.", + envelope.seq, + snapshot_seq + ); + } + #[test] #[ignore] fn stress_emit_and_snapshot_large_history_baseline() { diff --git a/refact-agent/engine/src/chat/tests.rs b/refact-agent/engine/src/chat/tests.rs index 776b1b132..20e432edd 100644 --- a/refact-agent/engine/src/chat/tests.rs +++ b/refact-agent/engine/src/chat/tests.rs @@ -684,6 +684,7 @@ mod tests { priority: false, command_type: "user_message".to_string(), preview: "Hello".to_string(), + content: "Hello".to_string(), }, ], }; diff --git a/refact-agent/engine/src/chat/trajectories.rs b/refact-agent/engine/src/chat/trajectories.rs index dc7885b4b..13f8098e0 100644 --- a/refact-agent/engine/src/chat/trajectories.rs +++ b/refact-agent/engine/src/chat/trajectories.rs @@ -2440,8 +2440,13 @@ pub async fn handle_v1_trajectories_subscribe( loop { match rx.recv().await { Ok(event) => { - let json = serde_json::to_string(&event).unwrap_or_default(); - yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)); + match serde_json::to_string(&event) { + Ok(json) => yield Ok::<_, std::convert::Infallible>(format!("data: {}\n\n", json)), + Err(e) => { + tracing::error!("Failed to serialize trajectory SSE event: {}", e); + break; + } + } } Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(broadcast::error::RecvError::Closed) => break, @@ -2996,6 +3001,7 @@ mod tests { trajectory_version: 5, created_at: "2024-01-01T00:00:00Z".to_string(), closed: false, + closed_flag: Arc::new(AtomicBool::new(false)), external_reload_pending: false, last_prompt_messages: Vec::new(), cache_guard_snapshot: None, diff --git a/refact-agent/engine/src/chat/types.rs b/refact-agent/engine/src/chat/types.rs index 48628c5e6..ef3eed7d4 100644 --- a/refact-agent/engine/src/chat/types.rs +++ b/refact-agent/engine/src/chat/types.rs @@ -206,6 +206,8 @@ pub struct QueuedItem { pub priority: bool, pub command_type: String, pub preview: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub content: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -510,59 +512,101 @@ pub struct CommandRequest { impl CommandRequest { pub fn to_queued_item(&self) -> QueuedItem { - let (command_type, preview) = match &self.command { + let (command_type, preview, content) = match &self.command { ChatCommand::UserMessage { content, .. } => { - ("user_message".to_string(), extract_preview(content)) + let full = extract_full_text_capped(content); + let preview = extract_preview(content); + ("user_message".to_string(), preview, full) } ChatCommand::RetryFromIndex { content, index, .. } => ( "retry_from_index".to_string(), format!("@{}: {}", index, extract_preview(content)), + String::new(), ), ChatCommand::SetParams { patch } => { let model = patch.get("model").and_then(|v| v.as_str()).unwrap_or(""); - ("set_params".to_string(), format!("model={}", model)) + ("set_params".to_string(), format!("model={}", model), String::new()) } - ChatCommand::Abort {} => ("abort".to_string(), String::new()), + ChatCommand::Abort {} => ("abort".to_string(), String::new(), String::new()), ChatCommand::ToolDecision { tool_call_id, accepted, } => ( "tool_decision".to_string(), format!("{}: {}", tool_call_id, accepted), + String::new(), ), ChatCommand::ToolDecisions { decisions } => ( "tool_decisions".to_string(), format!("{} decisions", decisions.len()), + String::new(), ), ChatCommand::IdeToolResult { tool_call_id, .. } => { - ("ide_tool_result".to_string(), tool_call_id.clone()) + ("ide_tool_result".to_string(), tool_call_id.clone(), String::new()) } ChatCommand::UpdateMessage { message_id, .. } => { - ("update_message".to_string(), message_id.clone()) + ("update_message".to_string(), message_id.clone(), String::new()) } ChatCommand::RemoveMessage { message_id, .. } => { - ("remove_message".to_string(), message_id.clone()) - } - ChatCommand::Regenerate {} => ("regenerate".to_string(), String::new()), - ChatCommand::RestoreMessages { messages } => { - ("restore_messages".to_string(), format!("{} messages", messages.len())) - } - ChatCommand::BranchFromChat { source_chat_id, .. } => { - ("branch_from_chat".to_string(), source_chat_id.clone()) - } - ChatCommand::BrowserContextDecision { pending_message_id, .. } => { - ("browser_context_decision".to_string(), pending_message_id.clone()) + ("remove_message".to_string(), message_id.clone(), String::new()) } + ChatCommand::Regenerate {} => ("regenerate".to_string(), String::new(), String::new()), + ChatCommand::RestoreMessages { messages } => ( + "restore_messages".to_string(), + format!("{} messages", messages.len()), + String::new(), + ), + ChatCommand::BranchFromChat { source_chat_id, .. } => ( + "branch_from_chat".to_string(), + source_chat_id.clone(), + String::new(), + ), + ChatCommand::BrowserContextDecision { pending_message_id, .. } => ( + "browser_context_decision".to_string(), + pending_message_id.clone(), + String::new(), + ), }; QueuedItem { client_request_id: self.client_request_id.clone(), priority: self.priority, command_type, preview, + content, } } } +const MAX_CONTENT_CHARS: usize = 8192; + +fn extract_full_text(content: &serde_json::Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + if let Some(arr) = content.as_array() { + return arr + .iter() + .find_map(|item| { + if item.get("type").and_then(|t| t.as_str()) == Some("text") { + item.get("text").and_then(|t| t.as_str()).map(String::from) + } else { + None + } + }) + .unwrap_or_default(); + } + String::new() +} + +fn extract_full_text_capped(content: &serde_json::Value) -> String { + let text = extract_full_text(content); + if text.chars().count() > MAX_CONTENT_CHARS { + format!("{}…", text.chars().take(MAX_CONTENT_CHARS).collect::()) + } else { + text + } +} + fn extract_preview(content: &serde_json::Value) -> String { let max_preview = presentation().preview_chars; let text = if let Some(s) = content.as_str() { @@ -607,6 +651,9 @@ pub struct ChatSession { pub trajectory_version: u64, pub created_at: String, pub closed: bool, + /// Mirrors `closed` as an atomic so SSE heartbeat loops can check it + /// without acquiring the session mutex on every tick. + pub closed_flag: Arc, pub external_reload_pending: bool, pub last_prompt_messages: Vec, pub cache_guard_snapshot: Option, diff --git a/refact-agent/gui/package-lock.json b/refact-agent/gui/package-lock.json index a58674e0e..0e9d529eb 100644 --- a/refact-agent/gui/package-lock.json +++ b/refact-agent/gui/package-lock.json @@ -15,8 +15,10 @@ "@tanstack/react-table": "^8.20.6", "@types/react": "^18.2.43", "debug": "^4.3.7", + "dompurify": "^3.3.1", "framer-motion": "^12.10.4", "graphql": "^16.11.0", + "mermaid": "^11.12.3", "react-arborist": "^3.4.3", "react-redux": "^9.1.2", "react-virtuoso": "^4.18.1", @@ -50,6 +52,7 @@ "@types/cytoscape": "^3.31.0", "@types/debug": "^4.1.12", "@types/diff": "^7.0.1", + "@types/dompurify": "^3.2.0", "@types/js-cookie": "^3.0.6", "@types/lodash.groupby": "^4.6.9", "@types/lodash.isequal": "^4.5.8", @@ -167,6 +170,26 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "engines": { + "node": ">=18" + } + }, "node_modules/@ardatan/relay-compiler": { "version": "12.0.3", "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-12.0.3.tgz", @@ -2223,6 +2246,11 @@ "node": ">=18" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==" + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -2260,6 +2288,40 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz", + "integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==", + "dependencies": { + "@chevrotain/gast": "11.1.1", + "@chevrotain/types": "11.1.1", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz", + "integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==", + "dependencies": { + "@chevrotain/types": "11.1.1", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz", + "integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz", + "integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz", + "integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4134,6 +4196,21 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, "node_modules/@inquirer/confirm": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", @@ -4610,6 +4687,14 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", + "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", + "dependencies": { + "langium": "^4.0.0" + } + }, "node_modules/@microsoft/api-extractor": { "version": "7.39.0", "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.39.0.tgz", @@ -10990,6 +11075,228 @@ "cytoscape": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -11018,6 +11325,16 @@ "integrity": "sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA==", "dev": true }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "dompurify": "*" + } + }, "node_modules/@types/ejs": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", @@ -11091,6 +11408,11 @@ "integrity": "sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==", "dev": true }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -11409,6 +11731,12 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -13364,6 +13692,30 @@ "node": "*" } }, + "node_modules/chevrotain": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", + "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.1", + "@chevrotain/gast": "11.1.1", + "@chevrotain/regexp-to-ast": "11.1.1", + "@chevrotain/types": "11.1.1", + "@chevrotain/utils": "11.1.1", + "lodash-es": "4.17.23" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -13898,8 +14250,7 @@ "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==" }, "node_modules/consola": { "version": "3.2.3", @@ -13999,7 +14350,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", - "dev": true, "dependencies": { "layout-base": "^2.0.0" } @@ -14139,16 +14489,38 @@ "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", - "dev": true, "engines": { "node": ">=0.10" } }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-cose-bilkent/node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cytoscape-cose-bilkent/node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" + }, "node_modules/cytoscape-fcose": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", - "dev": true, "dependencies": { "cose-base": "^2.2.0" }, @@ -14156,6 +14528,439 @@ "cytoscape": "^3.2.0" } }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -14228,6 +15033,11 @@ "dev": true, "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -14455,6 +15265,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -14638,6 +15456,14 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -16962,6 +17788,11 @@ "gunzip-maybe": "bin.js" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==" + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -17896,6 +18727,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -19138,10 +19977,9 @@ } }, "node_modules/katex": { - "version": "0.16.10", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz", - "integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==", - "dev": true, + "version": "0.16.33", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", + "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -19157,7 +19995,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, "engines": { "node": ">= 12" } @@ -19171,6 +20008,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -19205,11 +20047,26 @@ "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true }, + "node_modules/langium": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", + "dependencies": { + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.1.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, "node_modules/layout-base": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", - "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", - "dev": true + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==" }, "node_modules/lazy-universal-dotenv": { "version": "4.0.0", @@ -19643,6 +20500,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -20001,6 +20863,17 @@ "react": ">= 0.14.0" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -20565,6 +21438,45 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", + "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^1.0.0", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.23", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/meros": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.0.tgz", @@ -21331,22 +22243,20 @@ "dev": true }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", - "dev": true, + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" } }, "node_modules/mlly/node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "bin": { "acorn": "bin/acorn" }, @@ -21357,8 +22267,7 @@ "node_modules/mlly/node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, "node_modules/motion-dom": { "version": "12.15.0", @@ -22248,6 +23157,11 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==" + }, "node_modules/pako": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", @@ -22511,6 +23425,11 @@ "tslib": "^2.0.3" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -22699,7 +23618,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", @@ -22709,8 +23627,21 @@ "node_modules/pkg-types/node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } }, "node_modules/polished": { "version": "4.2.2", @@ -24381,6 +25312,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/rollup": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", @@ -24422,6 +25358,17 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -24463,6 +25410,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -24528,8 +25480,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.69.5", @@ -25354,6 +26305,11 @@ "inline-style-parser": "0.2.2" } }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==" + }, "node_modules/stylus": { "version": "0.59.0", "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.59.0.tgz", @@ -26014,7 +26970,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, "engines": { "node": ">=6.10" } @@ -26274,8 +27229,7 @@ "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==" }, "node_modules/uglify-js": { "version": "3.17.4", @@ -28119,6 +29073,49 @@ "node": ">=14.0.0" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" + }, "node_modules/vue-template-compiler": { "version": "2.7.16", "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", diff --git a/refact-agent/gui/package.json b/refact-agent/gui/package.json index 143940816..39e18bbbf 100644 --- a/refact-agent/gui/package.json +++ b/refact-agent/gui/package.json @@ -60,8 +60,10 @@ "@tanstack/react-table": "^8.20.6", "@types/react": "^18.2.43", "debug": "^4.3.7", + "dompurify": "^3.3.1", "framer-motion": "^12.10.4", "graphql": "^16.11.0", + "mermaid": "^11.12.3", "react-arborist": "^3.4.3", "react-redux": "^9.1.2", "react-virtuoso": "^4.18.1", @@ -95,6 +97,7 @@ "@types/cytoscape": "^3.31.0", "@types/debug": "^4.1.12", "@types/diff": "^7.0.1", + "@types/dompurify": "^3.2.0", "@types/js-cookie": "^3.0.6", "@types/lodash.groupby": "^4.6.9", "@types/lodash.isequal": "^4.5.8", diff --git a/refact-agent/gui/src/components/ChatContent/ChatContent.module.css b/refact-agent/gui/src/components/ChatContent/ChatContent.module.css index b9957e1d5..2c4139f58 100644 --- a/refact-agent/gui/src/components/ChatContent/ChatContent.module.css +++ b/refact-agent/gui/src/components/ChatContent/ChatContent.module.css @@ -215,6 +215,14 @@ word-break: break-word; } +.queuedMessageEditable { + cursor: pointer; +} + +.queuedMessageEditable:hover { + color: var(--gray-12); +} + .plainTextTrigger { cursor: pointer; color: var(--gray-10); diff --git a/refact-agent/gui/src/components/ChatContent/QueuedMessage.tsx b/refact-agent/gui/src/components/ChatContent/QueuedMessage.tsx index c46c7ae40..07397b844 100644 --- a/refact-agent/gui/src/components/ChatContent/QueuedMessage.tsx +++ b/refact-agent/gui/src/components/ChatContent/QueuedMessage.tsx @@ -1,12 +1,17 @@ -import React, { useCallback } from "react"; -import { Flex, Text, IconButton, Card, Badge } from "@radix-ui/themes"; +import React, { useCallback, useState } from "react"; +import { Flex, Text, IconButton, Card, Badge, Tooltip } from "@radix-ui/themes"; import { Cross1Icon, ClockIcon, LightningBoltIcon, } from "@radix-ui/react-icons"; -import { QueuedItem } from "../../features/Chat"; +import type { QueuedItem } from "../../features/Chat"; import { useChatActions } from "../../hooks"; +import { useAppSelector } from "../../hooks"; +import { selectLspPort, selectApiKey } from "../../features/Config/configSlice"; +import { selectChatId } from "../../features/Chat/Thread/selectors"; +import { sendUserMessage } from "../../services/refact/chatCommands"; +import { setInputValue } from "../ChatForm/actions"; import styles from "./ChatContent.module.css"; import classNames from "classnames"; @@ -15,56 +20,179 @@ type QueuedMessageProps = { position: number; }; +function postInputValue(text: string, sendImmediately: boolean) { + window.postMessage( + setInputValue({ value: text, send_immediately: sendImmediately }), + window.location.origin || "*", + ); +} + export const QueuedMessage: React.FC = ({ queuedItem, position, }) => { const { cancelQueued } = useChatActions(); + const port = useAppSelector(selectLspPort); + const apiKey = useAppSelector(selectApiKey); + const chatId = useAppSelector(selectChatId); + const [isWorking, setIsWorking] = useState(false); + + const content = queuedItem.content ?? ""; + const isEditable = + queuedItem.command_type === "user_message" && content.length > 0; + + const handleCancel = useCallback(async () => { + if (isWorking) return; + setIsWorking(true); + try { + await cancelQueued(queuedItem.client_request_id); + } catch (e) { + console.error("Failed to cancel queued message", e); + } finally { + setIsWorking(false); + } + }, [isWorking, cancelQueued, queuedItem.client_request_id]); + + const handleEdit = useCallback(async () => { + if (isWorking || !isEditable) return; + setIsWorking(true); + try { + const ok = await cancelQueued(queuedItem.client_request_id); + if (!ok) return; + postInputValue(content, queuedItem.priority); + } catch (e) { + console.error("Failed to edit queued message", e); + } finally { + setIsWorking(false); + } + }, [ + isWorking, + isEditable, + cancelQueued, + queuedItem.client_request_id, + queuedItem.priority, + content, + ]); + + const handleEditKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void handleEdit(); + } + }, + [handleEdit], + ); + + const handleTogglePriority = useCallback(async () => { + if (isWorking || !isEditable || !chatId || !port) return; + setIsWorking(true); + try { + const ok = await cancelQueued(queuedItem.client_request_id); + if (!ok) return; + try { + await sendUserMessage( + chatId, + content, + port, + apiKey ?? undefined, + !queuedItem.priority, + ); + } catch (sendError) { + console.error("Failed to re-enqueue with new priority", sendError); + postInputValue(content, queuedItem.priority); + } + } catch (e) { + console.error("Failed to toggle queued message priority", e); + } finally { + setIsWorking(false); + } + }, [ + isWorking, + isEditable, + chatId, + port, + apiKey, + cancelQueued, + queuedItem.client_request_id, + queuedItem.priority, + content, + ]); - const handleCancel = useCallback(() => { - void cancelQueued(queuedItem.client_request_id); - }, [cancelQueued, queuedItem.client_request_id]); + const tooltipContent = content || queuedItem.preview; return ( - - - - - {queuedItem.priority ? ( - - ) : ( - + + + + + + {queuedItem.priority ? ( + + ) : ( + + )} + {position} + + void handleEdit() : undefined} + onKeyDown={isEditable ? handleEditKeyDown : undefined} + > + {queuedItem.preview || `[${queuedItem.command_type}]`} + + + + {isEditable && ( + void handleTogglePriority()} + title={ + queuedItem.priority + ? "Change to normal queue" + : "Change to send next" + } + > + {queuedItem.priority ? ( + + ) : ( + + )} + )} - {position} - - - {queuedItem.preview || `[${queuedItem.command_type}]`} - + void handleCancel()} + title="Cancel queued message" + > + + + - - - - - + + ); }; diff --git a/refact-agent/gui/src/components/ChatForm/useInputValue.ts b/refact-agent/gui/src/components/ChatForm/useInputValue.ts index 70b0da570..301c53e45 100644 --- a/refact-agent/gui/src/components/ChatForm/useInputValue.ts +++ b/refact-agent/gui/src/components/ChatForm/useInputValue.ts @@ -30,6 +30,15 @@ export function useInputValue( const handleEvent = useCallback( (event: MessageEvent) => { + const isSameWindowPost = + event.source === window && window.location.origin !== "null"; + const isSameOrigin = + window.location.origin !== "null" && + event.origin === window.location.origin; + if (isSameWindowPost && !isSameOrigin) { + return; + } + if (addInputValue.match(event.data) || setInputValue.match(event.data)) { const { payload } = event.data; debugRefact( diff --git a/refact-agent/gui/src/components/Markdown/DiagramBlock.module.css b/refact-agent/gui/src/components/Markdown/DiagramBlock.module.css new file mode 100644 index 000000000..203e2ea86 --- /dev/null +++ b/refact-agent/gui/src/components/Markdown/DiagramBlock.module.css @@ -0,0 +1,56 @@ +.diagram_container { + position: relative; + border-radius: var(--radius-2); + background-color: var(--gray-a2); + overflow: hidden; +} + +.diagram_toolbar { + display: flex; + align-items: center; + gap: var(--space-1); + position: absolute; + top: var(--space-2); + right: var(--space-2); + z-index: 1; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.diagram_container:hover .diagram_toolbar, +.diagram_container:focus-within .diagram_toolbar { + opacity: 1; + pointer-events: auto; +} + +.diagram_render { + display: flex; + justify-content: center; + align-items: center; + padding: var(--space-4); + min-height: 80px; + overflow-x: auto; +} + +.diagram_render svg { + max-width: 100%; + height: auto; +} + +.diagram_loading { + display: flex; + justify-content: center; + align-items: center; + padding: var(--space-4); + min-height: 80px; + color: var(--gray-9); + font-size: var(--font-size-2); +} + +.error_hint { + padding: var(--space-2) var(--space-3); + color: var(--red-9); + font-size: var(--font-size-1); + border-top: 1px solid var(--gray-a4); +} diff --git a/refact-agent/gui/src/components/Markdown/MermaidBlock.tsx b/refact-agent/gui/src/components/Markdown/MermaidBlock.tsx new file mode 100644 index 000000000..62e7deeab --- /dev/null +++ b/refact-agent/gui/src/components/Markdown/MermaidBlock.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState, useId, useCallback } from "react"; +import { Box, IconButton, Tooltip } from "@radix-ui/themes"; +import { CopyIcon, CodeIcon, EyeOpenIcon } from "@radix-ui/react-icons"; +import { PreTag } from "./Pre"; +import styles from "./Markdown.module.css"; +import diagramStyles from "./DiagramBlock.module.css"; +import classNames from "classnames"; +import { useAppearance } from "../../hooks/useAppearance"; + +let mermaidInitialized: "dark" | "light" | null = null; + +async function getMermaid(theme: "dark" | "light") { + const mermaid = (await import("mermaid")).default; + if (mermaidInitialized !== theme) { + mermaid.initialize({ + startOnLoad: false, + theme: theme === "dark" ? "dark" : "default", + securityLevel: "strict", + fontFamily: + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + }); + mermaidInitialized = theme; + } + return mermaid; +} + +export type MermaidBlockProps = { + code: string; + onCopyClick?: (str: string) => void; +}; + +const _MermaidBlock: React.FC = ({ code, onCopyClick }) => { + const [svgHtml, setSvgHtml] = useState(null); + const [error, setError] = useState(null); + const [showSource, setShowSource] = useState(false); + const uniqueId = useId().replace(/:/g, "_"); + const { appearance } = useAppearance(); + const theme = appearance === "dark" ? "dark" : "light"; + + useEffect(() => { + let cancelled = false; + + const renderDiagram = async () => { + try { + const mermaid = await getMermaid(theme); + const { svg } = await mermaid.render( + `mermaid_${uniqueId}`, + code.trim(), + ); + + if (!cancelled) { + setSvgHtml(svg); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + setSvgHtml(null); + } + } + }; + + const timer = setTimeout(() => { + void renderDiagram(); + }, 100); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [code, uniqueId, theme]); + + const handleToggleSource = useCallback(() => { + setShowSource((v) => !v); + }, []); + + const handleCopy = useCallback(() => { + onCopyClick?.(code); + }, [onCopyClick, code]); + + if (error) { + return ( + + + + {code} + + + + Mermaid syntax error: {error} + + + ); + } + + return ( + + + + + + {showSource ? ( + + ) : ( + + )} + + + {onCopyClick && ( + + + + + + )} + + {showSource ? ( + + + {code} + + + ) : svgHtml ? ( + + ) : ( + Rendering… + )} + + + ); +}; + +export const MermaidBlock = React.memo(_MermaidBlock); diff --git a/refact-agent/gui/src/components/Markdown/ShikiCodeBlock.tsx b/refact-agent/gui/src/components/Markdown/ShikiCodeBlock.tsx index a7482cc21..608923625 100644 --- a/refact-agent/gui/src/components/Markdown/ShikiCodeBlock.tsx +++ b/refact-agent/gui/src/components/Markdown/ShikiCodeBlock.tsx @@ -7,6 +7,10 @@ import type { Element } from "hast"; import { trimIndent } from "../../utils"; import { useShiki } from "../../hooks/useShiki"; import { useAppearance } from "../../hooks/useAppearance"; +import { MermaidBlock } from "./MermaidBlock"; +import { SvgBlock } from "./SvgBlock"; + +const DIAGRAM_LANGUAGES = new Set(["mermaid", "svg"]); export type MarkdownControls = { onCopyClick: (str: string) => void; @@ -95,6 +99,16 @@ const _ShikiCodeBlock: React.FC = ({ return {}; }, [onCopyClick, textWithOutIndent]); + if (isBlock && DIAGRAM_LANGUAGES.has(language)) { + const diagramCode = textWithOutIndent ?? String(children); + if (language === "mermaid") { + return ; + } + if (language === "svg") { + return ; + } + } + if (!isBlock) { return ( void; +}; + +const _SvgBlock: React.FC = ({ code, onCopyClick }) => { + const [showSource, setShowSource] = useState(false); + + const sanitizedSvg = useMemo(() => { + const trimmed = code.trim(); + if (!trimmed.includes(" + + + {code} + + + + ); + } + + return ( + + + + + setShowSource((v) => !v)} + aria-label={showSource ? "Show rendered" : "Show source"} + > + {showSource ? ( + + ) : ( + + )} + + + {onCopyClick && ( + + onCopyClick(code)} + aria-label="Copy SVG source" + > + + + + )} + + {showSource ? ( + + + {code} + + + ) : ( + + )} + + + ); +}; + +export const SvgBlock = React.memo(_SvgBlock); diff --git a/refact-agent/gui/src/features/Chat/Thread/types.ts b/refact-agent/gui/src/features/Chat/Thread/types.ts index 42df4307f..3ff3383ed 100644 --- a/refact-agent/gui/src/features/Chat/Thread/types.ts +++ b/refact-agent/gui/src/features/Chat/Thread/types.ts @@ -33,6 +33,7 @@ export type QueuedItem = { priority: boolean; command_type: string; preview: string; + content?: string; }; export type IntegrationMeta = { diff --git a/refact-agent/gui/src/hooks/useAllChatsSubscription.ts b/refact-agent/gui/src/hooks/useAllChatsSubscription.ts index 17a33ebb4..6185706e5 100644 --- a/refact-agent/gui/src/hooks/useAllChatsSubscription.ts +++ b/refact-agent/gui/src/hooks/useAllChatsSubscription.ts @@ -46,9 +46,11 @@ export function useAllChatsSubscription() { Map> >(new Map()); const streamedBytesRef = useRef>(new Map()); + const pendingBytesRef = useRef>(new Map()); const portRef = useRef(port); const apiKeyRef = useRef(apiKey); const subscribeRef = useRef<((chatId: string) => void) | null>(null); + const unsubscribeRef = useRef<((chatId: string) => void) | null>(null); const enqueueStreamDeltaRef = useRef< | (( chatId: string, @@ -68,7 +70,7 @@ export function useAllChatsSubscription() { const ACTIVITY_THROTTLE_MS = 500; const MAX_MERGED_DELTA_OPS = 256; - // Adaptive flush thresholds (bytes of accumulated content) + // Adaptive flush thresholds (JS string length units, i.e. UTF-16 code units) const FLUSH_TIER_FAST_BYTES = 8_192; const FLUSH_TIER_MEDIUM_BYTES = 200_000; // Flush intervals per tier (ms) @@ -76,7 +78,7 @@ export function useAllChatsSubscription() { const FLUSH_MS_MEDIUM = 150; const FLUSH_MS_SLOW = 500; const FLUSH_MS_BACKGROUND = 500; - // Hard cap: force flush if buffered text exceeds this + // Hard cap: force flush if buffered char-count (UTF-16 units) exceeds this const MAX_BUFFERED_BYTES = 2_000_000; const activeChatId = currentThreadId; @@ -89,6 +91,16 @@ export function useAllChatsSubscription() { } }, []); + // Clear all per-chat streaming state. Used by unsubscribe() and the + // onError/onDisconnected callbacks so state never leaks between reconnects. + const clearChatStreamState = useCallback((chatId: string) => { + streamedBytesRef.current.delete(chatId); + pendingBytesRef.current.delete(chatId); + seqMapRef.current.delete(chatId); + lastActivityAtRef.current.delete(chatId); + lastActivityDispatchRef.current.delete(chatId); + }, []); + const clearStreamDeltaFlushForChat = useCallback((chatId: string) => { const timerId = streamDeltaFlushRef.current.get(chatId); if (timerId != null) { @@ -102,6 +114,7 @@ export function useAllChatsSubscription() { const pending = pendingStreamDeltaRef.current.get(chatId); if (!pending) return; pendingStreamDeltaRef.current.delete(chatId); + pendingBytesRef.current.delete(chatId); dispatch(applyChatEvent(pending)); }, [dispatch], @@ -141,32 +154,48 @@ export function useAllChatsSubscription() { chatId: string, envelope: Extract, ) => { - // Track accumulated content bytes for adaptive throttle + // streamedCharsRef: total chars seen in this stream (never decrements), + // used only for adaptive flush-tier selection. + // pendingCharsRef: chars currently sitting in the pending buffer, + // updated precisely after merge/replace — used for the force-flush cap. + let deltaTextLen = 0; for (const op of envelope.ops) { if (op.op === "append_content" || op.op === "append_reasoning") { - const prev = streamedBytesRef.current.get(chatId) ?? 0; - streamedBytesRef.current.set(chatId, prev + op.text.length); + deltaTextLen += op.text.length; } } + streamedBytesRef.current.set( + chatId, + (streamedBytesRef.current.get(chatId) ?? 0) + deltaTextLen, + ); const pending = pendingStreamDeltaRef.current.get(chatId); if (pending && pending.message_id === envelope.message_id) { const mergedOpsLen = pending.ops.length + envelope.ops.length; if (mergedOpsLen <= MAX_MERGED_DELTA_OPS) { + // Merging: add incoming chars to existing pending buffer pending.seq = envelope.seq; pending.ops.push(...envelope.ops); + pendingBytesRef.current.set( + chatId, + (pendingBytesRef.current.get(chatId) ?? 0) + deltaTextLen, + ); } else { - flushPendingStreamDeltaForChat(chatId); + // Too many ops: flush existing, start fresh with incoming envelope + flushPendingStreamDeltaForChat(chatId); // resets pendingBytesRef pendingStreamDeltaRef.current.set(chatId, envelope); + pendingBytesRef.current.set(chatId, deltaTextLen); } } else { - flushPendingStreamDeltaForChat(chatId); + // Different message or no pending: flush existing, start with incoming + flushPendingStreamDeltaForChat(chatId); // resets pendingBytesRef pendingStreamDeltaRef.current.set(chatId, envelope); + pendingBytesRef.current.set(chatId, deltaTextLen); } - // Force immediate flush if buffered text is too large - const bufferedBytes = streamedBytesRef.current.get(chatId) ?? 0; - if (bufferedBytes > MAX_BUFFERED_BYTES) { + // Force immediate flush if *buffered* (not total) chars exceed the cap + const bufferedChars = pendingBytesRef.current.get(chatId) ?? 0; + if (bufferedChars > MAX_BUFFERED_BYTES) { clearStreamDeltaFlushForChat(chatId); flushPendingStreamDeltaForChat(chatId); return; @@ -226,6 +255,7 @@ export function useAllChatsSubscription() { if (envelope.type === "snapshot") { flushPendingStreamDeltaForChatRef.current?.(chatId); streamedBytesRef.current.delete(chatId); + pendingBytesRef.current.delete(chatId); seqMapRef.current.set(chatId, seq); retryCountRef.current.set(chatId, 0); dispatch(setSseStatus({ chatId, status: "connected" })); @@ -233,12 +263,7 @@ export function useAllChatsSubscription() { if (seq <= lastSeq) return; if (seq > lastSeq + 1n) { flushPendingStreamDeltaForChatRef.current?.(chatId); - const unsub = subscriptionsRef.current.get(chatId); - if (unsub) { - manualCloseRef.current.add(chatId); - unsub(); - subscriptionsRef.current.delete(chatId); - } + unsubscribeRef.current?.(chatId); dispatch(setSseStatus({ chatId, status: "connecting" })); scheduleResubscribe(chatId, false); return; @@ -251,6 +276,7 @@ export function useAllChatsSubscription() { flushPendingStreamDeltaForChatRef.current?.(chatId); if (envelope.type === "stream_finished") { streamedBytesRef.current.delete(chatId); + pendingBytesRef.current.delete(chatId); } dispatch(applyChatEvent(envelope)); } @@ -262,6 +288,7 @@ export function useAllChatsSubscription() { clearStreamDeltaFlushForChatRef.current?.(chatId); flushPendingStreamDeltaForChatRef.current?.(chatId); subscriptionsRef.current.delete(chatId); + clearChatStreamState(chatId); const count = (retryCountRef.current.get(chatId) ?? 0) + 1; retryCountRef.current.set(chatId, count); dispatch( @@ -279,6 +306,7 @@ export function useAllChatsSubscription() { clearStreamDeltaFlushForChatRef.current?.(chatId); flushPendingStreamDeltaForChatRef.current?.(chatId); subscriptionsRef.current.delete(chatId); + clearChatStreamState(chatId); const count = (retryCountRef.current.get(chatId) ?? 0) + 1; retryCountRef.current.set(chatId, count); dispatch(setSseStatus({ chatId, status: "disconnected" })); @@ -302,7 +330,7 @@ export function useAllChatsSubscription() { subscriptionsRef.current.set(chatId, unsubscribe); }, - [dispatch, scheduleResubscribe], + [dispatch, scheduleResubscribe, clearChatStreamState], ); subscribeRef.current = subscribeToChat; @@ -314,21 +342,20 @@ export function useAllChatsSubscription() { clearPendingTimeout(chatId); clearStreamDeltaFlushForChat(chatId); pendingStreamDeltaRef.current.delete(chatId); - streamedBytesRef.current.delete(chatId); + clearChatStreamState(chatId); const unsub = subscriptionsRef.current.get(chatId); if (unsub) { unsub(); subscriptionsRef.current.delete(chatId); - seqMapRef.current.delete(chatId); retryCountRef.current.delete(chatId); - lastActivityDispatchRef.current.delete(chatId); - lastActivityAtRef.current.delete(chatId); dispatch(removeSseConnection({ chatId })); } }, - [dispatch, clearPendingTimeout, clearStreamDeltaFlushForChat], + [dispatch, clearPendingTimeout, clearStreamDeltaFlushForChat, clearChatStreamState], ); + unsubscribeRef.current = unsubscribe; + const unsubscribeAll = useCallback(() => { for (const chatId of subscriptionsRef.current.keys()) { manualCloseRef.current.add(chatId); @@ -353,6 +380,7 @@ export function useAllChatsSubscription() { streamDeltaFlushRef.current.clear(); pendingStreamDeltaRef.current.clear(); streamedBytesRef.current.clear(); + pendingBytesRef.current.clear(); dispatch(clearAllSseConnections()); }, [dispatch]); diff --git a/refact-agent/gui/src/hooks/useChatSubscription.ts b/refact-agent/gui/src/hooks/useChatSubscription.ts index c4951a5ff..d2fd5c142 100644 --- a/refact-agent/gui/src/hooks/useChatSubscription.ts +++ b/refact-agent/gui/src/hooks/useChatSubscription.ts @@ -87,17 +87,19 @@ export function useChatSubscription( { type: "stream_delta" } > | null>(null); const streamedBytesRef = useRef(0); + const pendingBytesRef = useRef(0); const connectingRef = useRef(false); // eslint-disable-next-line @typescript-eslint/no-empty-function const connectRef = useRef<() => void>(() => {}); const MAX_MERGED_DELTA_OPS = 256; - // Adaptive flush thresholds (bytes of accumulated content) + // Adaptive flush thresholds (JS string length units, i.e. UTF-16 code units) const FLUSH_TIER_FAST_BYTES = 8_192; const FLUSH_TIER_MEDIUM_BYTES = 200_000; const FLUSH_MS_MEDIUM = 150; const FLUSH_MS_SLOW = 500; + // Hard cap: force flush if buffered char-count (UTF-16 units) exceeds this const MAX_BUFFERED_BYTES = 2_000_000; const clearStreamDeltaFlush = useCallback(() => { @@ -112,6 +114,7 @@ export function useChatSubscription( const pending = pendingStreamDeltaRef.current; if (!pending) return; pendingStreamDeltaRef.current = null; + pendingBytesRef.current = 0; dispatch(applyChatEvent(pending)); callbacksRef.current.onEvent?.(pending); }, [dispatch]); @@ -139,28 +142,41 @@ export function useChatSubscription( const enqueueStreamDelta = useCallback( (envelope: Extract) => { + // streamedBytesRef: total chars seen this stream (never decrements), + // drives flush-tier selection. + // pendingBytesRef: chars currently buffered, updated precisely after + // merge/replace decision — drives the force-flush cap. + let deltaTextLen = 0; for (const op of envelope.ops) { if (op.op === "append_content" || op.op === "append_reasoning") { - streamedBytesRef.current += op.text.length; + deltaTextLen += op.text.length; } } + streamedBytesRef.current += deltaTextLen; const pending = pendingStreamDeltaRef.current; if (pending && pending.message_id === envelope.message_id) { const mergedOpsLen = pending.ops.length + envelope.ops.length; if (mergedOpsLen <= MAX_MERGED_DELTA_OPS) { + // Merging: add incoming chars to existing pending buffer pending.seq = envelope.seq; pending.ops.push(...envelope.ops); + pendingBytesRef.current += deltaTextLen; } else { - flushPendingStreamDelta(); + // Too many ops: flush existing, start fresh with incoming envelope + flushPendingStreamDelta(); // resets pendingBytesRef to 0 pendingStreamDeltaRef.current = envelope; + pendingBytesRef.current = deltaTextLen; } } else { - flushPendingStreamDelta(); + // Different message or no pending: flush existing, start with incoming + flushPendingStreamDelta(); // resets pendingBytesRef to 0 pendingStreamDeltaRef.current = envelope; + pendingBytesRef.current = deltaTextLen; } - if (streamedBytesRef.current > MAX_BUFFERED_BYTES) { + // Force immediate flush if *buffered* (not total) chars exceed the cap + if (pendingBytesRef.current > MAX_BUFFERED_BYTES) { clearStreamDeltaFlush(); flushPendingStreamDelta(); return; @@ -179,6 +195,7 @@ export function useChatSubscription( clearStreamDeltaFlush(); pendingStreamDeltaRef.current = null; streamedBytesRef.current = 0; + pendingBytesRef.current = 0; if (unsubscribeRef.current) { unsubscribeRef.current(); unsubscribeRef.current = null; @@ -230,6 +247,7 @@ export function useChatSubscription( ); } streamedBytesRef.current = 0; + pendingBytesRef.current = 0; lastSeqRef.current = seq; } else { if (seq <= lastSeqRef.current) { @@ -260,6 +278,7 @@ export function useChatSubscription( flushPendingStreamDelta(); if (envelope.type === "stream_finished") { streamedBytesRef.current = 0; + pendingBytesRef.current = 0; } dispatch(applyChatEvent(envelope)); callbacksRef.current.onEvent?.(envelope); From 70ecaa14bd0f806e119f215d722a553e0faa15e6 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Sat, 28 Feb 2026 02:16:32 +1030 Subject: [PATCH 005/181] feat(modes)!: add rich content rendering support (mermaid, svg, html) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `%RICH_CONTENT_INSTRUCTIONS%` snippet explaining rich fenced code blocks and update all default modes to schema v8+ with instructions included. Add frontend support for: - `html` blocks → interactive ArtifactBlock with live preview, source toggle, error handling, auto-resize, download/open-in-tab - Pass `isStreaming` prop through Markdown components - Update ShikiCodeBlock to route special blocks correctly BREAKING CHANGE: Mode schema versions incremented (7→8, 8→9, etc.) --- .../engine/src/chat/prompt_snippets.rs | 7 + refact-agent/engine/src/chat/prompts.rs | 7 + .../yaml_configs/defaults/modes/agent.yaml | 4 +- .../src/yaml_configs/defaults/modes/ask.yaml | 4 +- .../defaults/modes/configurator.yaml | 6 +- .../yaml_configs/defaults/modes/debug.yaml | 6 +- .../yaml_configs/defaults/modes/explore.yaml | 4 +- .../yaml_configs/defaults/modes/learn.yaml | 4 +- .../defaults/modes/openai_agent.yaml | 4 +- .../defaults/modes/past_work.yaml | 6 +- .../src/yaml_configs/defaults/modes/plan.yaml | 6 +- .../defaults/modes/project_summary.yaml | 4 +- .../defaults/modes/quick_agent.yaml | 4 +- .../yaml_configs/defaults/modes/review.yaml | 6 +- .../yaml_configs/defaults/modes/shell.yaml | 4 +- .../defaults/modes/task_agent.yaml | 4 +- .../defaults/modes/task_planner.yaml | 4 +- .../components/ChatContent/AssistantInput.tsx | 2 +- .../Markdown/ArtifactBlock.module.css | 41 +++ .../src/components/Markdown/ArtifactBlock.tsx | 323 ++++++++++++++++++ .../gui/src/components/Markdown/Markdown.tsx | 5 +- .../components/Markdown/ShikiCodeBlock.tsx | 20 ++ 22 files changed, 453 insertions(+), 22 deletions(-) create mode 100644 refact-agent/gui/src/components/Markdown/ArtifactBlock.module.css create mode 100644 refact-agent/gui/src/components/Markdown/ArtifactBlock.tsx diff --git a/refact-agent/engine/src/chat/prompt_snippets.rs b/refact-agent/engine/src/chat/prompt_snippets.rs index b03b73583..fea825d50 100644 --- a/refact-agent/engine/src/chat/prompt_snippets.rs +++ b/refact-agent/engine/src/chat/prompt_snippets.rs @@ -62,3 +62,10 @@ pub const AGENT_EXECUTION_INSTRUCTIONS_NO_TOOLS: &str = r#" - Propose the chang - the exact files/functions to modify or create - the new or updated tests to add - the expected outcome and success criteria"#; + +pub const RICH_CONTENT_INSTRUCTIONS: &str = r#"The chat window renders rich visual content from fenced code blocks. When you write these, the user sees the rendered result directly in the conversation (not raw code): +- ` ```mermaid ` — the user sees a rendered Mermaid diagram (flowcharts, sequence diagrams, ER diagrams, etc.) +- ` ```svg ` — the user sees the rendered SVG image inline +- ` ```html ` — the user sees a live interactive preview in a sandboxed iframe (HTML + CSS + JS). You can load CDN libraries via `; + + const trimmed = userCode.trim(); + const isCompleteDocument = + trimmed.toLowerCase().startsWith(""); + if (bodyCloseIdx !== -1) { + return ( + trimmed.slice(0, bodyCloseIdx) + + injectedStyles + + injectedScripts + + trimmed.slice(bodyCloseIdx) + ); + } + const htmlCloseIdx = trimmed.toLowerCase().lastIndexOf(""); + if (htmlCloseIdx !== -1) { + return ( + trimmed.slice(0, htmlCloseIdx) + + injectedStyles + + injectedScripts + + trimmed.slice(htmlCloseIdx) + ); + } + return trimmed + injectedStyles + injectedScripts; + } + + return ` + +${injectedStyles} + +${userCode} +${injectedScripts} + +`; +} + +const _ArtifactBlock: React.FC = ({ + code, + isStreaming = false, + onCopyClick, +}) => { + const [showSource, setShowSource] = useState(false); + const [isOpen, setIsOpen] = useState(true); + const [height, setHeight] = useState(200); + const [error, setError] = useState(null); + const iframeRef = useRef(null); + const prevStreaming = useRef(isStreaming); + const { appearance } = useAppearance(); + const isDark = appearance === "dark"; + + useEffect(() => { + if (prevStreaming.current && !isStreaming) { + setShowSource(false); + } + prevStreaming.current = isStreaming; + }, [isStreaming]); + + const wrappedHtml = useMemo( + () => wrapArtifactHtml(code, isDark), + [code, isDark], + ); + + useEffect(() => { + let lastMessageTime = 0; + const handler = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) return; + const data = event.data as Record | null; + if (!data || typeof data.type !== "string") return; + + const now = Date.now(); + if (now - lastMessageTime < MIN_MESSAGE_INTERVAL_MS) return; + lastMessageTime = now; + + if (data.type === "refact-artifact-resize") { + const h = Number(data.height); + if (h > 0) { + setHeight(Math.min(h, MAX_IFRAME_HEIGHT)); + } + } + if (data.type === "refact-artifact-error") { + const msg = String(data.message).slice(0, MAX_ERROR_MESSAGE_LENGTH); + setError(msg); + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + useEffect(() => { + setError(null); + }, [code]); + + const handleToggle = useCallback(() => setIsOpen((v) => !v), []); + const handleToggleSource = useCallback(() => setShowSource((v) => !v), []); + + const handleCopy = useCallback(() => { + onCopyClick?.(code); + }, [onCopyClick, code]); + + const handleOpenInTab = useCallback(() => { + const wrapperHtml = ` +HTML Preview + + + + +`; + const blob = new Blob([wrapperHtml], { type: "text/html" }); + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener,noreferrer"); + setTimeout(() => URL.revokeObjectURL(url), 60000); + }, [wrappedHtml]); + + const handleDownload = useCallback(() => { + const blob = new Blob([wrappedHtml], { type: "text/html" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "artifact.html"; + a.click(); + URL.revokeObjectURL(url); + }, [wrappedHtml]); + + const lineCount = useMemo(() => code.split("\n").length, [code]); + + const status: ToolStatus = useMemo(() => { + if (isStreaming) return "running"; + if (error) return "error"; + return "success"; + }, [isStreaming, error]); + + const effectiveShowSource = isStreaming || showSource; + const showIframe = !isStreaming && !showSource; + + return ( + } + summary="HTML Preview" + meta={`${lineCount} lines`} + status={status} + isOpen={isOpen} + onToggle={handleToggle} + > + + + + + {showSource ? ( + + ) : ( + + )} + + + + + + + + + + + + + + {onCopyClick && ( + + + + + + )} + + + {effectiveShowSource && ( + + + + {code} + + + + )} + + {showIframe && ( + - + `; const blob = new Blob([wrapperHtml], { type: "text/html" }); const url = URL.createObjectURL(blob); @@ -312,9 +314,7 @@ const _ArtifactBlock: React.FC = ({ /> )} - {error && ( - JS Error: {error} - )} + {error && JS Error: {error}} ); diff --git a/refact-agent/gui/src/features/Extensions/Extensions.tsx b/refact-agent/gui/src/features/Extensions/Extensions.tsx index 18c4e7e03..059cf6731 100644 --- a/refact-agent/gui/src/features/Extensions/Extensions.tsx +++ b/refact-agent/gui/src/features/Extensions/Extensions.tsx @@ -52,11 +52,18 @@ export const Extensions: React.FC = ({ initialTab === "commands" ? initialItemId ?? null : null, ); const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [createDialogType, setCreateDialogType] = useState<"skill" | "command">("skill"); + const [createDialogType, setCreateDialogType] = useState<"skill" | "command">( + "skill", + ); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(null); - const { data: registry, isLoading, isError, refetch } = useGetExtRegistryQuery(undefined); + const { + data: registry, + isLoading, + isError, + refetch, + } = useGetExtRegistryQuery(undefined); const [deleteSkill] = useDeleteSkillMutation(); const [deleteCommand] = useDeleteCommandMutation(); @@ -102,7 +109,14 @@ export const Extensions: React.FC = ({ setDeleteError(message); } setDeleteTarget(null); - }, [deleteTarget, deleteSkill, deleteCommand, selectedSkill, selectedCommand, refetch]); + }, [ + deleteTarget, + deleteSkill, + deleteCommand, + selectedSkill, + selectedCommand, + refetch, + ]); const openCreateDialog = useCallback((type: "skill" | "command") => { setCreateDialogType(type); @@ -158,7 +172,6 @@ export const Extensions: React.FC = ({ Hooks Marketplace - @@ -169,8 +182,8 @@ export const Extensions: React.FC = ({ )}
- {activeTab === "skills" && ( - selectedSkill ? ( + {activeTab === "skills" && + (selectedSkill ? ( setSelectedSkill(null)} @@ -183,11 +196,10 @@ export const Extensions: React.FC = ({ onCreate={() => openCreateDialog("skill")} onDelete={handleDeleteSkill} /> - ) - )} + ))} - {activeTab === "commands" && ( - selectedCommand ? ( + {activeTab === "commands" && + (selectedCommand ? ( setSelectedCommand(null)} @@ -200,14 +212,11 @@ export const Extensions: React.FC = ({ onCreate={() => openCreateDialog("command")} onDelete={handleDeleteCommand} /> - ) - )} + ))} {activeTab === "hooks" && } {activeTab === "marketplace" && } - -
= ({ Confirm Delete - {`Delete ${deleteTarget?.type ?? ""} "${deleteTarget?.name ?? ""}"?`} + {`Delete ${deleteTarget?.type ?? ""} "${ + deleteTarget?.name ?? "" + }"?`} diff --git a/refact-agent/gui/src/features/Extensions/components/CommandEditor.tsx b/refact-agent/gui/src/features/Extensions/components/CommandEditor.tsx index 10b5c62c6..7ccc848c3 100644 --- a/refact-agent/gui/src/features/Extensions/components/CommandEditor.tsx +++ b/refact-agent/gui/src/features/Extensions/components/CommandEditor.tsx @@ -8,7 +8,12 @@ import { SegmentedControl, Callout, } from "@radix-ui/themes"; -import { ArrowLeftIcon, MixerHorizontalIcon, CodeIcon, InfoCircledIcon } from "@radix-ui/react-icons"; +import { + ArrowLeftIcon, + MixerHorizontalIcon, + CodeIcon, + InfoCircledIcon, +} from "@radix-ui/react-icons"; import { useGetCommandQuery, useSaveCommandMutation, @@ -26,20 +31,24 @@ type CommandFormProps = { disabled: boolean; }; -const CommandForm: React.FC = ({ data, onChange, disabled }) => { +const CommandForm: React.FC = ({ + data, + onChange, + disabled, +}) => { return ( - Name - + + Name + + - Description + + Description +