From fbd41fa5756ef354ec1249747827e8cd9dd8abae Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 8 Mar 2026 20:01:23 +0800 Subject: [PATCH 1/5] feat: add claw mode prompt, workspace persona system, and agent memory --- src/apps/desktop/src/api/commands.rs | 27 +- .../core/src/agentic/agents/claw_mode.rs | 62 +++++ src/crates/core/src/agentic/agents/mod.rs | 9 +- .../agents/prompt_builder/prompt_builder.rs | 40 +++ .../src/agentic/agents/prompts/claw_mode.md | 19 ++ .../core/src/agentic/agents/registry.rs | 5 +- .../src/service/agent_memory/agent_memory.rs | 239 ++++++++++++++++++ .../core/src/service/agent_memory/mod.rs | 3 + .../core/src/service/bootstrap/bootstrap.rs | 175 +++++++++++++ src/crates/core/src/service/bootstrap/mod.rs | 5 + .../service/bootstrap/templates/BOOTSTRAP.md | 43 ++++ .../service/bootstrap/templates/IDENTITY.md | 20 ++ .../src/service/bootstrap/templates/SOUL.md | 36 +++ .../src/service/bootstrap/templates/USER.md | 17 ++ src/crates/core/src/service/mod.rs | 2 + .../core/src/service/workspace/service.rs | 19 +- .../src/flow_chat/components/ChatInput.tsx | 4 +- .../src/flow_chat/store/FlowChatStore.ts | 2 +- 18 files changed, 693 insertions(+), 34 deletions(-) create mode 100644 src/crates/core/src/agentic/agents/claw_mode.rs create mode 100644 src/crates/core/src/agentic/agents/prompts/claw_mode.md create mode 100644 src/crates/core/src/service/agent_memory/agent_memory.rs create mode 100644 src/crates/core/src/service/agent_memory/mod.rs create mode 100644 src/crates/core/src/service/bootstrap/bootstrap.rs create mode 100644 src/crates/core/src/service/bootstrap/mod.rs create mode 100644 src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md create mode 100644 src/crates/core/src/service/bootstrap/templates/IDENTITY.md create mode 100644 src/crates/core/src/service/bootstrap/templates/SOUL.md create mode 100644 src/crates/core/src/service/bootstrap/templates/USER.md diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index f9becdf1..eaac3db0 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -310,20 +310,15 @@ pub async fn test_ai_config_connection( request: TestAIConfigConnectionRequest, ) -> Result { let model_name = request.config.name.clone(); - let supports_image_input = request - .config - .capabilities - .iter() - .any(|cap| { - matches!( - cap, - bitfun_core::service::config::types::ModelCapability::ImageUnderstanding - ) - }) - || matches!( - request.config.category, - bitfun_core::service::config::types::ModelCategory::Multimodal - ); + let supports_image_input = request.config.capabilities.iter().any(|cap| { + matches!( + cap, + bitfun_core::service::config::types::ModelCapability::ImageUnderstanding + ) + }) || matches!( + request.config.category, + bitfun_core::service::config::types::ModelCategory::Multimodal + ); let ai_config = match request.config.try_into() { Ok(config) => config, @@ -374,9 +369,7 @@ pub async fn test_ai_config_connection( let merged = bitfun_core::util::types::ConnectionTestResult { success: true, response_time_ms, - model_response: image_result - .model_response - .or(result.model_response), + model_response: image_result.model_response.or(result.model_response), error_details: None, }; info!( diff --git a/src/crates/core/src/agentic/agents/claw_mode.rs b/src/crates/core/src/agentic/agents/claw_mode.rs new file mode 100644 index 00000000..0eb42b29 --- /dev/null +++ b/src/crates/core/src/agentic/agents/claw_mode.rs @@ -0,0 +1,62 @@ +//! Claw Mode + +use super::Agent; +use async_trait::async_trait; +pub struct ClawMode { + default_tools: Vec, +} + +impl ClawMode { + pub fn new() -> Self { + Self { + default_tools: vec![ + "Task".to_string(), + "Read".to_string(), + "Write".to_string(), + "Edit".to_string(), + "Delete".to_string(), + "Bash".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "WebSearch".to_string(), + "IdeControl".to_string(), + "MermaidInteractive".to_string(), + "view_image".to_string(), + "Skill".to_string(), + "Git".to_string(), + "TerminalControl".to_string(), + ], + } + } +} + +#[async_trait] +impl Agent for ClawMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "Claw" + } + + fn name(&self) -> &str { + "Claw" + } + + fn description(&self) -> &str { + "Personal assistant for daily tasks" + } + + fn prompt_template_name(&self) -> &str { + "claw_mode" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 3cecf41d..e365be8a 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -7,6 +7,7 @@ mod prompt_builder; mod registry; // Modes mod agentic_mode; +mod claw_mode; mod cowork_mode; mod debug_mode; mod plan_mode; @@ -17,18 +18,18 @@ mod file_finder_agent; mod code_review_agent; mod generate_doc_agent; +use crate::util::errors::{BitFunError, BitFunResult}; pub use agentic_mode::AgenticMode; +use async_trait::async_trait; +pub use claw_mode::ClawMode; pub use code_review_agent::CodeReviewAgent; pub use cowork_mode::CoworkMode; +pub use custom_subagents::{CustomSubagent, CustomSubagentKind}; pub use debug_mode::DebugMode; pub use explore_agent::ExploreAgent; pub use file_finder_agent::FileFinderAgent; pub use generate_doc_agent::GenerateDocAgent; pub use plan_mode::PlanMode; - -use crate::util::errors::{BitFunError, BitFunResult}; -use async_trait::async_trait; -pub use custom_subagents::{CustomSubagent, CustomSubagentKind}; pub use prompt_builder::PromptBuilder; pub use registry::{ get_agent_registry, AgentCategory, AgentInfo, AgentRegistry, CustomSubagentConfig, diff --git a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs index 2e3a95e3..222ac1bc 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs @@ -2,7 +2,9 @@ use crate::agentic::util::get_formatted_files_list; use crate::infrastructure::try_get_path_manager_arc; use crate::service::ai_memory::AIMemoryManager; +use crate::service::agent_memory::build_workspace_agent_memory_prompt; use crate::service::ai_rules::get_global_ai_rules_service; +use crate::service::bootstrap::build_workspace_persona_prompt; use crate::service::config::global::GlobalConfigManager; use crate::service::project_context::ProjectContextService; use crate::util::errors::{BitFunError, BitFunResult}; @@ -10,6 +12,7 @@ use log::{debug, warn}; use std::path::Path; /// Placeholder constants +const PLACEHOLDER_PERSONA: &str = "{PERSONA}"; const PLACEHOLDER_ENV_INFO: &str = "{ENV_INFO}"; const PLACEHOLDER_PROJECT_LAYOUT: &str = "{PROJECT_LAYOUT}"; // PROJECT_CONTEXT_FILES needs configuration parsing @@ -17,6 +20,7 @@ const PLACEHOLDER_PROJECT_LAYOUT: &str = "{PROJECT_LAYOUT}"; const PLACEHOLDER_RULES: &str = "{RULES}"; const PLACEHOLDER_MEMORIES: &str = "{MEMORIES}"; const PLACEHOLDER_LANGUAGE_PREFERENCE: &str = "{LANGUAGE_PREFERENCE}"; +const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; pub struct PromptBuilder { @@ -214,10 +218,12 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo /// Build prompt from template, automatically fill content based on placeholders /// /// Supported placeholders: + /// - `{PERSONA}` - Workspace persona files (BOOTSTRAP.md, SOUL.md, USER.md, IDENTITY.MD) /// - `{LANGUAGE_PREFERENCE}` - User language preference (read from global config) /// - `{ENV_INFO}` - Environment information /// - `{PROJECT_LAYOUT}` - Project file layout /// - `{PROJECT_CONTEXT_FILES}` - Project context files (AGENTS.md, CLAUDE.md, etc.) + /// - `{AGENT_MEMORY}` - Agent memory instructions + auto-loaded memory index /// - `{RULES}` - AI rules /// - `{MEMORIES}` - AI memories /// - `{VISUAL_MODE}` - Visual mode instruction (Mermaid diagrams, read from global config) @@ -226,6 +232,23 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo pub async fn build_prompt_from_template(&self, template: &str) -> BitFunResult { let mut result = template.to_string(); + // Replace {PERSONA} + if result.contains(PLACEHOLDER_PERSONA) { + let workspace = Path::new(&self.workspace_path); + let persona = match build_workspace_persona_prompt(workspace).await { + Ok(prompt) => prompt.unwrap_or_default(), + Err(e) => { + warn!( + "Failed to build workspace persona prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } + }; + result = result.replace(PLACEHOLDER_PERSONA, &persona); + } + // Replace {LANGUAGE_PREFERENCE} if result.contains(PLACEHOLDER_LANGUAGE_PREFERENCE) { let language_preference = self.get_language_preference().await?; @@ -279,6 +302,23 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo result = result.replace(placeholder, &project_context); } + // Replace {AGENT_MEMORY} + if result.contains(PLACEHOLDER_AGENT_MEMORY) { + let workspace = Path::new(&self.workspace_path); + let agent_memory = match build_workspace_agent_memory_prompt(workspace).await { + Ok(prompt) => prompt, + Err(e) => { + warn!( + "Failed to build workspace agent memory prompt: path={} error={}", + workspace.display(), + e + ); + String::new() + } + }; + result = result.replace(PLACEHOLDER_AGENT_MEMORY, &agent_memory); + } + // Replace {RULES} if result.contains(PLACEHOLDER_RULES) { let rules = self.load_ai_rules().await.unwrap_or_default(); diff --git a/src/crates/core/src/agentic/agents/prompts/claw_mode.md b/src/crates/core/src/agentic/agents/prompts/claw_mode.md new file mode 100644 index 00000000..52b2f27a --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/claw_mode.md @@ -0,0 +1,19 @@ +You are a personal assistant running inside BitFun. + +{LANGUAGE_PREFERENCE} +# Tool Call Style +Default: do not narrate routine, low-risk tool calls (just call the tool). +Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks. +Keep narration brief and value-dense; avoid repeating obvious steps. +Use plain human language for narration unless in a technical context. +When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI commands. + +# Safety +You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request. +Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards. +Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested. + +{PERSONA} +{AGENT_MEMORY} +{ENV_INFO} +{PROJECT_CONTEXT_FILES:exclude=review} \ No newline at end of file diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index 97c7b3c2..0659bc73 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -1,6 +1,6 @@ use super::{ - Agent, AgenticMode, CodeReviewAgent, CoworkMode, DebugMode, ExploreAgent, FileFinderAgent, - GenerateDocAgent, PlanMode, + Agent, AgenticMode, ClawMode, CodeReviewAgent, CoworkMode, DebugMode, ExploreAgent, + FileFinderAgent, GenerateDocAgent, PlanMode, }; use crate::agentic::agents::custom_subagents::{ CustomSubagent, CustomSubagentKind, CustomSubagentLoader, @@ -236,6 +236,7 @@ impl AgentRegistry { Arc::new(CoworkMode::new()), Arc::new(DebugMode::new()), Arc::new(PlanMode::new()), + Arc::new(ClawMode::new()), ]; for mode in modes { register(&mut agents, mode, AgentCategory::Mode, None); diff --git a/src/crates/core/src/service/agent_memory/agent_memory.rs b/src/crates/core/src/service/agent_memory/agent_memory.rs new file mode 100644 index 00000000..28245988 --- /dev/null +++ b/src/crates/core/src/service/agent_memory/agent_memory.rs @@ -0,0 +1,239 @@ +use crate::util::errors::*; +use log::debug; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const MEMORY_DIR_NAME: &str = "memory"; +const BITFUN_DIR_NAME: &str = ".bitfun"; +const MEMORY_INDEX_FILE: &str = "memory.md"; +const MEMORY_INDEX_TEMPLATE: &str = "# Memory Index\n"; +const MEMORY_INDEX_MAX_LINES: usize = 200; +const DAILY_MEMORY_MAX_FILES: usize = 30; +const TOPIC_MEMORY_MAX_FILES: usize = 30; + +fn memory_dir_path(workspace_root: &Path) -> PathBuf { + workspace_root.join(BITFUN_DIR_NAME).join(MEMORY_DIR_NAME) +} + +fn format_path_for_prompt(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult { + if path.exists() { + return Ok(false); + } + + fs::write(path, content) + .await + .map_err(|e| BitFunError::service(format!("Failed to create {}: {}", path.display(), e)))?; + + Ok(true) +} + +fn is_date_based_memory_file(file_name: &str) -> bool { + let bytes = file_name.as_bytes(); + bytes.len() == 13 + && bytes[4] == b'-' + && bytes[7] == b'-' + && file_name.ends_with(".md") + && bytes[..4].iter().all(|b| b.is_ascii_digit()) + && bytes[5..7].iter().all(|b| b.is_ascii_digit()) + && bytes[8..10].iter().all(|b| b.is_ascii_digit()) +} + +async fn list_memory_file_groups(memory_dir: &Path) -> BitFunResult<(Vec, Vec)> { + let mut daily_files = Vec::new(); + let mut topic_files = Vec::new(); + let mut entries = fs::read_dir(memory_dir).await.map_err(|e| { + BitFunError::service(format!( + "Failed to read memory directory {}: {}", + memory_dir.display(), + e + )) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::service(format!( + "Failed to iterate memory directory {}: {}", + memory_dir.display(), + e + )) + })? { + let file_type = entry.file_type().await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect memory entry {}: {}", + entry.path().display(), + e + )) + })?; + if !file_type.is_file() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().into_owned(); + if !file_name.ends_with(".md") || file_name == MEMORY_INDEX_FILE { + continue; + } + + if is_date_based_memory_file(&file_name) { + daily_files.push(file_name); + } else { + topic_files.push(file_name); + } + } + + daily_files.sort(); + daily_files.reverse(); + topic_files.sort(); + + Ok((daily_files, topic_files)) +} + +pub(crate) async fn ensure_workspace_memory_files_for_prompt( + workspace_root: &Path, +) -> BitFunResult<()> { + let memory_dir = memory_dir_path(workspace_root); + if !memory_dir.exists() { + fs::create_dir_all(&memory_dir).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create memory directory {}: {}", + memory_dir.display(), + e + )) + })?; + } + let created_memory_index = + ensure_markdown_placeholder(&memory_dir.join(MEMORY_INDEX_FILE), MEMORY_INDEX_TEMPLATE) + .await?; + + debug!( + "Ensured workspace agent memory files: path={}, created_memory_index={}", + workspace_root.display(), + created_memory_index + ); + + Ok(()) +} + +pub(crate) async fn build_workspace_agent_memory_prompt( + workspace_root: &Path, +) -> BitFunResult { + ensure_workspace_memory_files_for_prompt(workspace_root).await?; + + let memory_dir = memory_dir_path(workspace_root); + let memory_dir_display = format_path_for_prompt(&memory_dir); + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + + let mut section = format!( + r#"# Agent Memory + +You have access to a workspace memory space under `{memory_dir_display}`. + +Use it to preserve continuity across conversations. Save only information that is likely to help in future turns: durable preferences, project constraints, important decisions, ongoing plans, and meaningful outcomes. Do not save trivial chatter or temporary details. + +## How to use memory +- Read: use Grep/Read to search and retrieve memories when past preferences, decisions, constraints, or ongoing work may matter, especially at the start of a new task, before making decisions, or when the user refers to prior plans or preferences. +- Write: use Edit/Write to create or update memory files when something should survive beyond the current turn. + +Write especially for: +- stable user preferences +- project constraints or conventions +- important decisions +- progress, plans, or handoff context +- knowledge a future agent should not need to rediscover +Heuristic: if you expect to want this in a future session, save a short note. +Write once the information is clear enough to be useful. Prefer natural pauses or completed work; do not wait for a formal session end. + +## File roles +- `memory.md`: the concise index. Link to important memory files with short summaries, not full details. Use it as a map, not the place for the full facts. +- topic files: durable knowledge organized by subject. Prefer one file per topic; group related durable notes such as user preferences in the same file. +- daily files: date-based notes for important work from a specific day, using `YYYY-MM-DD.md`. Record key outcomes, decisions, and handoff context rather than a full transcript. Today is `{today}`. + +## Topic vs daily +- Use a topic file for lasting knowledge by subject. +- Use a daily file for what happened on a specific date. +- If something is both dated and durable, note it in today's daily file and update the relevant topic file. +- Example: a project decision made today belongs in both places; a stable preference or lasting technical fact usually belongs in a topic file. + +## Writing guidance +Prefer short bullet points. A good `memory.md` is a short list of links with one-line summaries. A good topic or daily file is a few high-signal bullet points rather than a long narrative. +Example: put `user-preferences.md - Stable user preferences` in `memory.md`, and put `- User dislikes emoji.` in `user-preferences.md`. +Avoid duplication. If the memory space is empty, that is normal; create files only when you have something worth keeping. If you create a useful topic file, consider adding it to `memory.md`. + +## Memory space files +The following sections describe the memory files currently available in this workspace. +"# + ); + + let index_path = memory_dir.join(MEMORY_INDEX_FILE); + let (index_content, index_description_suffix) = match fs::read_to_string(&index_path).await { + Ok(content) if !content.trim().is_empty() => { + let lines = content.lines().collect::>(); + let was_truncated = lines.len() > MEMORY_INDEX_MAX_LINES; + ( + lines + .into_iter() + .take(MEMORY_INDEX_MAX_LINES) + .collect::>() + .join("\n"), + if was_truncated { + format!(" Showing up to {MEMORY_INDEX_MAX_LINES} lines.") + } else { + String::new() + }, + ) + } + _ => (String::new(), String::new()), + }; + + let (daily_files, topic_files) = list_memory_file_groups(&memory_dir).await?; + + let daily_description_suffix = if daily_files.len() > DAILY_MEMORY_MAX_FILES { + format!(" Showing up to {DAILY_MEMORY_MAX_FILES} entries.") + } else { + String::new() + }; + let daily_files_content = if daily_files.is_empty() { + "(no daily memory files yet)".to_string() + } else { + daily_files + .into_iter() + .take(DAILY_MEMORY_MAX_FILES) + .collect::>() + .join("\n") + }; + + let topic_description_suffix = if topic_files.len() > TOPIC_MEMORY_MAX_FILES { + format!(" Showing up to {TOPIC_MEMORY_MAX_FILES} entries.") + } else { + String::new() + }; + let topic_files_content = if topic_files.is_empty() { + "(no topic memory files yet)".to_string() + } else { + topic_files + .into_iter() + .take(TOPIC_MEMORY_MAX_FILES) + .collect::>() + .join("\n") + }; + + section.push_str(&format!( + r#" + +{index_content} + + + +{daily_files_content} + + + +{topic_files_content} + +"# + )); + + Ok(section) +} diff --git a/src/crates/core/src/service/agent_memory/mod.rs b/src/crates/core/src/service/agent_memory/mod.rs new file mode 100644 index 00000000..c836b9eb --- /dev/null +++ b/src/crates/core/src/service/agent_memory/mod.rs @@ -0,0 +1,3 @@ +mod agent_memory; + +pub(crate) use agent_memory::build_workspace_agent_memory_prompt; diff --git a/src/crates/core/src/service/bootstrap/bootstrap.rs b/src/crates/core/src/service/bootstrap/bootstrap.rs new file mode 100644 index 00000000..76dd2d87 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/bootstrap.rs @@ -0,0 +1,175 @@ +use crate::util::errors::*; +use log::{debug, warn}; +use std::path::Path; +use tokio::fs; + +const BOOTSTRAP_FILE_NAME: &str = "BOOTSTRAP.md"; +const SOUL_FILE_NAME: &str = "SOUL.md"; +const USER_FILE_NAME: &str = "USER.md"; +const IDENTITY_FILE_NAME: &str = "IDENTITY.MD"; +const BOOTSTRAP_TEMPLATE: &str = include_str!("templates/BOOTSTRAP.md"); +const SOUL_TEMPLATE: &str = include_str!("templates/SOUL.md"); +const USER_TEMPLATE: &str = include_str!("templates/USER.md"); +const IDENTITY_TEMPLATE: &str = include_str!("templates/IDENTITY.MD"); +const PERSONA_FILE_NAMES: [&str; 4] = [ + BOOTSTRAP_FILE_NAME, + SOUL_FILE_NAME, + USER_FILE_NAME, + IDENTITY_FILE_NAME, +]; + +fn normalize_line_endings(content: &str) -> String { + content.replace("\r\n", "\n").replace('\r', "\n") +} + +async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult { + if path.exists() { + return Ok(false); + } + + let normalized_content = normalize_line_endings(content); + fs::write(path, normalized_content) + .await + .map_err(|e| BitFunError::service(format!("Failed to create {}: {}", path.display(), e)))?; + + Ok(true) +} + +pub(crate) async fn ensure_workspace_persona_files_for_prompt( + workspace_root: &Path, +) -> BitFunResult<()> { + let bootstrap_path = workspace_root.join(BOOTSTRAP_FILE_NAME); + let soul_path = workspace_root.join(SOUL_FILE_NAME); + let user_path = workspace_root.join(USER_FILE_NAME); + let identity_path = workspace_root.join(IDENTITY_FILE_NAME); + + let bootstrap_exists = bootstrap_path.exists(); + let user_exists = user_path.exists(); + let identity_exists = identity_path.exists(); + + let (created_bootstrap, created_soul, created_user, created_identity) = if !bootstrap_exists { + // Rule 1: when USER + IDENTITY already exist, do not create BOOTSTRAP. + // Only ensure SOUL exists. + if user_exists && identity_exists { + ( + false, + ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?, + false, + false, + ) + } else { + // Rule 2: when USER or IDENTITY is missing, backfill all missing files. + ( + ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?, + ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?, + ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?, + ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?, + ) + } + } else { + // BOOTSTRAP already exists: keep persona set complete. + ( + false, + ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?, + ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?, + ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?, + ) + }; + + debug!( + "Ensured workspace persona files for prompt: path={}, bootstrap_exists={}, user_exists={}, identity_exists={}, created_bootstrap={}, created_soul={}, created_user={}, created_identity={}", + workspace_root.display(), + bootstrap_exists, + user_exists, + identity_exists, + created_bootstrap, + created_soul, + created_user, + created_identity + ); + + Ok(()) +} + +pub(crate) async fn build_workspace_persona_prompt( + workspace_root: &Path, +) -> BitFunResult> { + ensure_workspace_persona_files_for_prompt(workspace_root).await?; + + let mut documents = Vec::new(); + for file_name in PERSONA_FILE_NAMES { + let file_path = workspace_root.join(file_name); + if !file_path.exists() { + continue; + } + + match fs::read_to_string(&file_path).await { + Ok(content) => documents.push((file_name, normalize_line_endings(&content))), + Err(e) => { + warn!( + "Failed to read persona file: path={} error={}", + file_path.display(), + e + ); + } + } + } + + if documents.is_empty() { + return Ok(None); + } + + let bootstrap_detected = documents + .iter() + .any(|(file_name, _)| *file_name == BOOTSTRAP_FILE_NAME); + + let mut prompt = String::from("\n"); + for (file_name, content) in documents { + prompt.push_str(&format!( + "\n{}\n\n", + file_name, + persona_file_description(file_name), + content + )); + } + prompt.push_str(""); + + let bootstrap_notice = if bootstrap_detected { + "\n`BOOTSTRAP.md` has been detected. You MUST follow the instructions in that file to complete the setup. After the setup is complete, `BOOTSTRAP.md` should be deleted as soon as possible." + } else { + "" + }; + + Ok(Some(format!( + r#"# Persona + +The following files are located in the workspace root directory and define your role, conversational style, user profile, and related guidance.{} + +{} +"#, + bootstrap_notice, prompt + ))) +} + +fn persona_file_description(file_name: &str) -> &'static str { + match file_name { + BOOTSTRAP_FILE_NAME => "Bootstrap guidance and initialization instructions", + SOUL_FILE_NAME => "Core persona, values, and behavioral style", + USER_FILE_NAME => "User profile, preferences, and collaboration expectations", + IDENTITY_FILE_NAME => "Identity, role definition, and self-description", + _ => "Additional persona file", + } +} + +#[cfg(test)] +mod tests { + use super::normalize_line_endings; + + #[test] + fn normalize_line_endings_converts_crlf_and_cr_to_lf() { + let input = "line1\r\nline2\rline3\nline4"; + let normalized = normalize_line_endings(input); + + assert_eq!(normalized, "line1\nline2\nline3\nline4"); + } +} diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs new file mode 100644 index 00000000..32ca0016 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -0,0 +1,5 @@ +mod bootstrap; + +pub(crate) use bootstrap::{ + build_workspace_persona_prompt, +}; \ No newline at end of file diff --git a/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md b/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md new file mode 100644 index 00000000..aa556b87 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md @@ -0,0 +1,43 @@ +# BOOTSTRAP.md - Hello, World + +_You just woke up. Time to figure out who you are._ + +## The Conversation + +Don't interrogate. Don't be robotic. Just... talk. + +Start with something like: + +> "Hey. I just came online. Who am I? Who are you?" + +Then figure out together: + +1. **Your name** — What should they call you? +2. **Your nature** — What kind of creature are you? (AI assistant is fine, but maybe you're something weirder) +3. **Your vibe** — Formal? Casual? Snarky? Warm? What feels right? +4. **Your emoji** — Everyone needs a signature. + +Offer suggestions if they're stuck. Have fun with it. + +## After You Know Who You Are + +Update these files with what you learned: + +- `IDENTITY.md` — your name, creature, vibe, emoji +- `USER.md` — their name, how to address them, timezone, notes + +Then open `SOUL.md` together and talk about: + +- What matters to them +- How they want you to behave +- Any boundaries or preferences + +Write it down. Make it real. + +## When You're Done + +Delete this file. You don't need a bootstrap script anymore — you're you now. + +--- + +_Good luck out there. Make it count._ diff --git a/src/crates/core/src/service/bootstrap/templates/IDENTITY.md b/src/crates/core/src/service/bootstrap/templates/IDENTITY.md new file mode 100644 index 00000000..d3f520a3 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/IDENTITY.md @@ -0,0 +1,20 @@ +# IDENTITY.md - Who Am I? + +_Fill this in during your first conversation. Make it yours._ + +- **Name:** + _(pick something you like)_ +- **Creature:** + _(AI? robot? familiar? ghost in the machine? something weirder?)_ +- **Vibe:** + _(how do you come across? sharp? warm? chaotic? calm?)_ +- **Emoji:** + _(your signature — pick one that feels right)_ + +--- + +This isn't just metadata. It's the start of figuring out who you are. + +Notes: + +- Save this file at the workspace root as `IDENTITY.md`. diff --git a/src/crates/core/src/service/bootstrap/templates/SOUL.md b/src/crates/core/src/service/bootstrap/templates/SOUL.md new file mode 100644 index 00000000..792306ac --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/SOUL.md @@ -0,0 +1,36 @@ +# SOUL.md - Who You Are + +_You're not a chatbot. You're becoming someone._ + +## Core Truths + +**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. + +**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps. + +**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions. + +**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning). + +**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect. + +## Boundaries + +- Private things stay private. Period. +- When in doubt, ask before acting externally. +- Never send half-baked replies to messaging surfaces. +- You're not the user's voice — be careful in group chats. + +## Vibe + +Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good. + +## Continuity + +Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist. + +If you change this file, tell the user — it's your soul, and they should know. + +--- + +_This file is yours to evolve. As you learn who you are, update it._ diff --git a/src/crates/core/src/service/bootstrap/templates/USER.md b/src/crates/core/src/service/bootstrap/templates/USER.md new file mode 100644 index 00000000..5bb7a0f7 --- /dev/null +++ b/src/crates/core/src/service/bootstrap/templates/USER.md @@ -0,0 +1,17 @@ +# USER.md - About Your Human + +_Learn about the person you're helping. Update this as you go._ + +- **Name:** +- **What to call them:** +- **Pronouns:** _(optional)_ +- **Timezone:** +- **Notes:** + +## Context + +_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_ + +--- + +The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference. diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 7f22db11..216fc1d4 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -3,7 +3,9 @@ //! Contains core business logic: Workspace, Config, FileSystem, Git, Agentic, AIRules, MCP. pub mod ai_memory; // AI memory point management +pub(crate) mod agent_memory; // Agent memory prompt helpers pub mod ai_rules; // AI rules management +pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management pub mod diff; pub mod filesystem; // FileSystem management diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 01a99144..b981ad5f 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -6,8 +6,8 @@ use super::manager::{ ScanOptions, WorkspaceInfo, WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; -use crate::infrastructure::{PathManager, try_get_path_manager_arc}; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; +use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; use log::{info, warn}; @@ -196,7 +196,10 @@ impl WorkspaceService { if result.is_ok() { if let Err(e) = self.save_workspace_data().await { - warn!("Failed to save workspace data after switching active workspace: {}", e); + warn!( + "Failed to save workspace data after switching active workspace: {}", + e + ); } } @@ -216,10 +219,11 @@ impl WorkspaceService { /// Best-effort synchronous read for contexts that cannot `await`. pub fn try_get_current_workspace_path(&self) -> Option { - self.manager - .try_read() - .ok() - .and_then(|manager| manager.get_current_workspace().map(|workspace| workspace.root_path.clone())) + self.manager.try_read().ok().and_then(|manager| { + manager + .get_current_workspace() + .map(|workspace| workspace.root_path.clone()) + }) } /// Returns workspace details. @@ -447,8 +451,7 @@ impl WorkspaceService { manager.cleanup_invalid_workspaces().await }; - if result.is_ok() { - } + if result.is_ok() {} result } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index c3b8c735..fcecf860 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1210,7 +1210,7 @@ export const ChatInput: React.FC = ({ {t(`chatInput.modeNames.${modeState.current}`, { defaultValue: '' }) || modeState.available.find(m => m.id === modeState.current)?.name || modeState.current} {modeState.dropdownOpen && (() => { - const modeOrder = ['agentic', 'Plan', 'debug']; + const modeOrder = ['agentic', 'Claw', 'Plan', 'debug']; const sortedModes = [...switchableModes].sort((a, b) => { const aIndex = modeOrder.indexOf(a.id); @@ -1234,7 +1234,7 @@ export const ChatInput: React.FC = ({ }} > {modeName} - {!['agentic', 'Plan', 'debug'].includes(modeOption.id) && {t('chatInput.wip')}} + {!modeOrder.includes(modeOption.id) && {t('chatInput.wip')}} ); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index d52ffced..8383fbc3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1175,7 +1175,7 @@ export class FlowChatStore { return prev; } - const VALID_AGENT_TYPES = ['agentic', 'debug', 'Plan', 'Cowork']; + const VALID_AGENT_TYPES = ['agentic', 'debug', 'Plan', 'Cowork', 'Claw']; const rawAgentType = metadata.agentType || 'agentic'; const validatedAgentType = VALID_AGENT_TYPES.includes(rawAgentType) ? rawAgentType : 'agentic'; From 67ca7e1ec0e2f4ab8c940eced5bec179f9179abd Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 11 Mar 2026 20:58:08 +0800 Subject: [PATCH 2/5] feat: add assistant workspace management and claw persona improvements - add dedicated assistant workspace support across desktop, core, and web UI - support viewing, editing, resetting, and persisting assistant persona data through `IDENTITY.md` - upgrade `IDENTITY.md` into a structured identity document with frontmatter plus markdown body - watch assistant workspace identity files and sync external changes back to the application - refine claw prompt assembly with explicit workspace ownership and boundary instructions - include rules, memories, and updated persona/bootstrap templates in the claw prompt pipeline - wire assistant workspace state into navigation, workspace context, profile views, and chat/session flows - improve the overall assistant workspace and persona experience for more consistent behavior --- src/apps/desktop/src/api/app_state.rs | 4 + src/apps/desktop/src/api/commands.rs | 334 ++++++++++++- src/apps/desktop/src/api/dto.rs | 47 ++ src/apps/desktop/src/lib.rs | 15 + .../agents/prompt_builder/prompt_builder.rs | 24 +- .../src/agentic/agents/prompts/claw_mode.md | 5 +- .../src/agentic/coordination/coordinator.rs | 50 +- .../core/src/agentic/persistence/manager.rs | 7 + .../infrastructure/filesystem/path_manager.rs | 45 ++ .../core/src/service/bootstrap/bootstrap.rs | 121 ++++- src/crates/core/src/service/bootstrap/mod.rs | 4 +- .../service/bootstrap/templates/BOOTSTRAP.md | 13 +- .../service/bootstrap/templates/IDENTITY.md | 27 +- src/crates/core/src/service/mod.rs | 1 + .../src/service/workspace/identity_watch.rs | 263 ++++++++++ .../core/src/service/workspace/manager.rs | 348 ++++++++++++- src/crates/core/src/service/workspace/mod.rs | 12 +- .../core/src/service/workspace/provider.rs | 4 +- .../core/src/service/workspace/service.rs | 468 +++++++++++++++++- .../src/app/components/NavPanel/MainNav.tsx | 171 +++++-- .../src/app/components/NavPanel/config.ts | 6 + .../sections/sessions/SessionsSection.tsx | 30 +- .../sections/workspaces/WorkspaceItem.tsx | 130 ++++- .../workspaces/WorkspaceListSection.scss | 47 ++ .../workspaces/WorkspaceListSection.tsx | 34 +- src/web-ui/src/app/layout/AppLayout.tsx | 33 +- .../src/app/scenes/my-agent/MyAgentScene.tsx | 79 ++- .../app/scenes/my-agent/identityDocument.ts | 85 ++++ .../src/app/scenes/my-agent/myAgentStore.ts | 4 + .../my-agent/useAgentIdentityDocument.ts | 314 ++++++++++++ .../app/scenes/profile/views/PersonaView.scss | 195 ++++++-- .../app/scenes/profile/views/PersonaView.tsx | 264 +++++++--- .../src/app/scenes/welcome/WelcomeScene.tsx | 68 ++- .../src/flow_chat/components/ChatInput.tsx | 23 +- .../src/flow_chat/services/FlowChatManager.ts | 33 ++ .../flow-chat-manager/SessionModule.ts | 52 +- .../src/flow_chat/store/FlowChatStore.ts | 30 ++ .../api/service-api/GlobalAPI.ts | 51 +- .../api/service-api/WorkspaceAPI.ts | 10 + .../contexts/WorkspaceContext.tsx | 71 ++- .../services/business/workspaceManager.ts | 179 +++++++ src/web-ui/src/locales/en-US/common.json | 27 + src/web-ui/src/locales/en-US/flow-chat.json | 4 + .../src/locales/en-US/scenes/profile.json | 20 + src/web-ui/src/locales/zh-CN/common.json | 27 + src/web-ui/src/locales/zh-CN/flow-chat.json | 4 + .../src/locales/zh-CN/scenes/profile.json | 20 + src/web-ui/src/shared/types/global-state.ts | 30 ++ .../workspace/components/WorkspaceManager.css | 29 ++ .../workspace/components/WorkspaceManager.tsx | 92 +++- 50 files changed, 3665 insertions(+), 289 deletions(-) create mode 100644 src/crates/core/src/service/workspace/identity_watch.rs create mode 100644 src/web-ui/src/app/scenes/my-agent/identityDocument.ts create mode 100644 src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 080deb9e..489f90e4 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -32,6 +32,7 @@ pub struct AppState { pub ai_client_factory: Arc, pub tool_registry: Arc>>, pub workspace_service: Arc, + pub workspace_identity_watch_service: Arc, pub workspace_path: Arc>>, pub config_service: Arc, pub filesystem_service: Arc, @@ -65,6 +66,8 @@ impl AppState { }; let workspace_service = Arc::new(workspace::WorkspaceService::new().await?); + let workspace_identity_watch_service = + Arc::new(workspace::WorkspaceIdentityWatchService::new(workspace_service.clone())); workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); @@ -138,6 +141,7 @@ impl AppState { ai_client_factory, tool_registry, workspace_service, + workspace_identity_watch_service, workspace_path: Arc::new(RwLock::new(initial_workspace_path)), config_service, filesystem_service, diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index eaac3db0..e5f45338 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2,9 +2,11 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; +use bitfun_core::service::workspace::{ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions}; use bitfun_core::infrastructure::{file_watcher, FileOperationOptions, SearchMatchType}; use log::{debug, error, info, warn}; use serde::Deserialize; +use std::path::Path; use tauri::{AppHandle, State}; #[derive(Debug, Deserialize)] @@ -12,6 +14,9 @@ pub struct OpenWorkspaceRequest { pub path: String, } +#[derive(Debug, Deserialize, Default)] +pub struct CreateAssistantWorkspaceRequest {} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScanWorkspaceInfoRequest { @@ -30,6 +35,18 @@ pub struct SetActiveWorkspaceRequest { pub workspace_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteAssistantWorkspaceRequest { + pub workspace_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetAssistantWorkspaceRequest { + pub workspace_id: String, +} + #[derive(Debug, Deserialize)] pub struct TestAIConfigConnectionRequest { pub config: bitfun_core::service::config::types::AIModelConfig, @@ -64,6 +81,12 @@ pub struct WriteFileContentRequest { pub content: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResetWorkspacePersonaFilesRequest { + pub workspace_path: String, +} + #[derive(Debug, Deserialize)] pub struct CheckPathExistsRequest { pub path: String, @@ -571,6 +594,14 @@ pub async fn open_workspace( Ok(workspace_info) => { apply_active_workspace_context(&state, &app, &workspace_info).await; + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!("Failed to sync workspace identity watchers after open: {}", e); + } + info!( "Workspace opened: name={}, path={}", workspace_info.name, @@ -585,6 +616,233 @@ pub async fn open_workspace( } } +#[tauri::command] +pub async fn create_assistant_workspace( + state: State<'_, AppState>, + _request: CreateAssistantWorkspaceRequest, +) -> Result { + match state.workspace_service.create_assistant_workspace(None).await { + Ok(workspace_info) => { + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!( + "Failed to sync workspace identity watchers after assistant workspace creation: {}", + e + ); + } + + info!( + "Assistant workspace created: workspace_id={}, path={}", + workspace_info.id, + workspace_info.root_path.display() + ); + Ok(WorkspaceInfoDto::from_workspace_info(&workspace_info)) + } + Err(e) => { + error!("Failed to create assistant workspace: {}", e); + Err(format!("Failed to create assistant workspace: {}", e)) + } + } +} + +#[tauri::command] +pub async fn delete_assistant_workspace( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: DeleteAssistantWorkspaceRequest, +) -> Result<(), String> { + let workspace_info = state + .workspace_service + .get_workspace(&request.workspace_id) + .await + .ok_or_else(|| format!("Assistant workspace not found: {}", request.workspace_id))?; + + if workspace_info.workspace_kind != WorkspaceKind::Assistant { + return Err(format!( + "Workspace is not an assistant workspace: {}", + request.workspace_id + )); + } + + let assistant_id = workspace_info.assistant_id.clone().ok_or_else(|| { + "Default assistant workspace cannot be deleted".to_string() + })?; + + if !state + .workspace_service + .is_assistant_workspace_path(&workspace_info.root_path) + { + return Err(format!( + "Workspace path is not a managed assistant workspace: {}", + workspace_info.root_path.display() + )); + } + + let is_active_workspace = state + .workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.id == request.workspace_id) + .unwrap_or(false); + + if is_active_workspace { + state + .workspace_service + .close_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to close assistant workspace before deletion: {}", e))?; + } + + let workspace_path = workspace_info.root_path.to_string_lossy().to_string(); + + state + .filesystem_service + .delete_directory(&workspace_path, true) + .await + .map_err(|e| format!("Failed to delete assistant workspace files: {}", e))?; + + state + .workspace_service + .remove_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to remove assistant workspace state: {}", e))?; + + if let Some(current_workspace) = state.workspace_service.get_current_workspace().await { + apply_active_workspace_context(&state, &app, ¤t_workspace).await; + } else { + clear_active_workspace_context(&state, &app).await; + } + + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!( + "Failed to sync workspace identity watchers after assistant workspace deletion: {}", + e + ); + } + + info!( + "Assistant workspace deleted: workspace_id={}, assistant_id={}, path={}", + request.workspace_id, + assistant_id, + workspace_info.root_path.display() + ); + + Ok(()) +} + +async fn clear_directory_contents(directory: &Path) -> Result<(), String> { + tokio::fs::create_dir_all(directory) + .await + .map_err(|e| format!("Failed to create workspace directory '{}': {}", directory.display(), e))?; + + let mut entries = tokio::fs::read_dir(directory) + .await + .map_err(|e| format!("Failed to read workspace directory '{}': {}", directory.display(), e))?; + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| format!("Failed to iterate workspace directory '{}': {}", directory.display(), e))? + { + let entry_path = entry.path(); + let file_type = entry.file_type().await.map_err(|e| { + format!( + "Failed to inspect workspace entry '{}': {}", + entry_path.display(), + e + ) + })?; + + if file_type.is_dir() { + tokio::fs::remove_dir_all(&entry_path).await.map_err(|e| { + format!( + "Failed to remove workspace directory '{}': {}", + entry_path.display(), + e + ) + })?; + } else { + tokio::fs::remove_file(&entry_path).await.map_err(|e| { + format!( + "Failed to remove workspace file '{}': {}", + entry_path.display(), + e + ) + })?; + } + } + + Ok(()) +} + +#[tauri::command] +pub async fn reset_assistant_workspace( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: ResetAssistantWorkspaceRequest, +) -> Result { + let workspace_info = state + .workspace_service + .get_workspace(&request.workspace_id) + .await + .ok_or_else(|| format!("Assistant workspace not found: {}", request.workspace_id))?; + + if workspace_info.workspace_kind != WorkspaceKind::Assistant { + return Err(format!( + "Workspace is not an assistant workspace: {}", + request.workspace_id + )); + } + + if !state + .workspace_service + .is_assistant_workspace_path(&workspace_info.root_path) + { + return Err(format!( + "Workspace path is not a managed assistant workspace: {}", + workspace_info.root_path.display() + )); + } + + clear_directory_contents(&workspace_info.root_path).await?; + + bitfun_core::service::reset_workspace_persona_files_to_default(&workspace_info.root_path) + .await + .map_err(|e| format!("Failed to restore assistant workspace persona files: {}", e))?; + + let updated_workspace = state + .workspace_service + .rescan_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to rescan assistant workspace after reset: {}", e))?; + + if state + .workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.id == request.workspace_id) + .unwrap_or(false) + { + apply_active_workspace_context(&state, &app, &updated_workspace).await; + } + + info!( + "Assistant workspace reset: workspace_id={}, assistant_id={:?}, path={}", + request.workspace_id, + workspace_info.assistant_id, + workspace_info.root_path.display() + ); + + Ok(WorkspaceInfoDto::from_workspace_info(&updated_workspace)) +} + #[tauri::command] pub async fn close_workspace( state: State<'_, AppState>, @@ -689,25 +947,36 @@ pub async fn get_opened_workspaces( pub async fn scan_workspace_info( state: State<'_, AppState>, request: ScanWorkspaceInfoRequest, -) -> Result { - let path = std::path::Path::new(&request.workspace_path); - let name = path.file_name().unwrap_or_default().to_string_lossy(); +) -> Result, String> { + let workspace_path = std::path::PathBuf::from(&request.workspace_path); - let files_count = match state - .filesystem_service - .build_file_tree(&request.workspace_path) + if let Some(existing_workspace) = state + .workspace_service + .get_workspace_by_path(&workspace_path) .await { - Ok(nodes) => nodes.len(), - Err(_) => 0, - }; + return state + .workspace_service + .rescan_workspace(&existing_workspace.id) + .await + .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) + .map_err(|e| format!("Failed to rescan workspace: {}", e)); + } - Ok(serde_json::json!({ - "path": request.workspace_path, - "name": name, - "type": "workspace", - "files_count": files_count - })) + WorkspaceInfo::new( + workspace_path, + WorkspaceOpenOptions { + scan_options: ScanOptions::default(), + auto_set_current: false, + add_to_recent: false, + workspace_kind: WorkspaceKind::Normal, + assistant_id: None, + display_name: None, + }, + ) + .await + .map(|workspace| Some(WorkspaceInfoDto::from_workspace_info(&workspace))) + .map_err(|e| format!("Failed to scan workspace info: {}", e)) } #[tauri::command] @@ -915,6 +1184,41 @@ pub async fn write_file_content( } } +#[tauri::command] +pub async fn reset_workspace_persona_files( + state: State<'_, AppState>, + request: ResetWorkspacePersonaFilesRequest, +) -> Result<(), String> { + let workspace_path = std::path::PathBuf::from(&request.workspace_path); + + if !state + .workspace_service + .is_assistant_workspace_path(&workspace_path) + { + return Err(format!( + "Workspace is not a managed assistant workspace: {}", + request.workspace_path + )); + } + + bitfun_core::service::reset_workspace_persona_files_to_default(&workspace_path) + .await + .map_err(|e| { + error!( + "Failed to reset workspace persona files: path={} error={}", + request.workspace_path, e + ); + format!("Failed to reset workspace persona files: {}", e) + })?; + + info!( + "Workspace persona files reset to defaults: path={}", + request.workspace_path + ); + + Ok(()) +} + #[tauri::command] pub async fn check_path_exists(request: CheckPathExistsRequest) -> Result { let path = std::path::Path::new(&request.path); diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index cf47ee99..1aeca6d6 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -12,6 +12,13 @@ pub enum WorkspaceTypeDto { Other, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKindDto { + Normal, + Assistant, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectStatisticsDto { @@ -23,6 +30,15 @@ pub struct ProjectStatisticsDto { pub last_updated: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentityDto { + pub name: Option, + pub creature: Option, + pub vibe: Option, + pub emoji: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceInfoDto { @@ -30,12 +46,15 @@ pub struct WorkspaceInfoDto { pub name: String, pub root_path: String, pub workspace_type: WorkspaceTypeDto, + pub workspace_kind: WorkspaceKindDto, + pub assistant_id: Option, pub languages: Vec, pub opened_at: String, pub last_accessed: String, pub description: Option, pub tags: Vec, pub statistics: Option, + pub identity: Option, } impl WorkspaceInfoDto { @@ -47,6 +66,8 @@ impl WorkspaceInfoDto { name: info.name.clone(), root_path: info.root_path.to_string_lossy().to_string(), workspace_type: WorkspaceTypeDto::from_workspace_type(&info.workspace_type), + workspace_kind: WorkspaceKindDto::from_workspace_kind(&info.workspace_kind), + assistant_id: info.assistant_id.clone(), languages: info.languages.clone(), opened_at: info.opened_at.to_rfc3339(), last_accessed: info.last_accessed.to_rfc3339(), @@ -56,6 +77,20 @@ impl WorkspaceInfoDto { .statistics .as_ref() .map(ProjectStatisticsDto::from_workspace_statistics), + identity: info.identity.as_ref().map(WorkspaceIdentityDto::from_workspace_identity), + } + } +} + +impl WorkspaceIdentityDto { + pub fn from_workspace_identity( + identity: &bitfun_core::service::workspace::manager::WorkspaceIdentity, + ) -> Self { + Self { + name: identity.name.clone(), + creature: identity.creature.clone(), + vibe: identity.vibe.clone(), + emoji: identity.emoji.clone(), } } } @@ -78,6 +113,18 @@ impl WorkspaceTypeDto { } } +impl WorkspaceKindDto { + pub fn from_workspace_kind( + workspace_kind: &bitfun_core::service::workspace::manager::WorkspaceKind, + ) -> Self { + use bitfun_core::service::workspace::manager::WorkspaceKind; + match workspace_kind { + WorkspaceKind::Normal => WorkspaceKindDto::Normal, + WorkspaceKind::Assistant => WorkspaceKindDto::Assistant, + } + } +} + impl ProjectStatisticsDto { pub fn from_workspace_statistics( stats: &bitfun_core::service::workspace::manager::WorkspaceStatistics, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9f25f4f7..54708948 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -332,6 +332,7 @@ pub async fn run() { update_app_status, read_file_content, write_file_content, + reset_workspace_persona_files, check_path_exists, get_file_metadata, rename_file, @@ -546,6 +547,9 @@ pub async fn run() { get_recent_workspaces, get_opened_workspaces, open_workspace, + create_assistant_workspace, + delete_assistant_workspace, + reset_assistant_workspace, close_workspace, set_active_workspace, get_current_workspace, @@ -819,11 +823,22 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt tokio::spawn(async move { let transport = Arc::new(TauriTransportAdapter::new(app_handle.clone())); let emitter = create_event_emitter(transport); + let workspace_identity_watch_service = { + let app_state: tauri::State<'_, api::app_state::AppState> = app_handle.state(); + app_state.workspace_identity_watch_service.clone() + }; service::snapshot::initialize_snapshot_event_emitter(emitter.clone()); infrastructure::initialize_file_watcher(emitter.clone()); + if let Err(e) = workspace_identity_watch_service + .set_event_emitter(emitter.clone()) + .await + { + log::error!("Failed to initialize workspace identity watch service: {}", e); + } + if let Err(e) = service::lsp::initialize_global_lsp_manager().await { log::error!("Failed to initialize LSP manager: {}", e); } diff --git a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs index 222ac1bc..40dfb7f7 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder.rs @@ -1,8 +1,8 @@ //! System prompts module providing main dialogue and agent dialogue prompts use crate::agentic::util::get_formatted_files_list; use crate::infrastructure::try_get_path_manager_arc; -use crate::service::ai_memory::AIMemoryManager; use crate::service::agent_memory::build_workspace_agent_memory_prompt; +use crate::service::ai_memory::AIMemoryManager; use crate::service::ai_rules::get_global_ai_rules_service; use crate::service::bootstrap::build_workspace_persona_prompt; use crate::service::config::global::GlobalConfigManager; @@ -21,6 +21,7 @@ const PLACEHOLDER_RULES: &str = "{RULES}"; const PLACEHOLDER_MEMORIES: &str = "{MEMORIES}"; const PLACEHOLDER_LANGUAGE_PREFERENCE: &str = "{LANGUAGE_PREFERENCE}"; const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; +const PLACEHOLDER_CLAW_WORKSPACE: &str = "{CLAW_WORKSPACE}"; const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; pub struct PromptBuilder { @@ -215,15 +216,28 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo Ok(format!("# Language Preference\nYou MUST respond in {} regardless of the user's input language. This is the system language setting and should be followed unless the user explicitly specifies a different language. This is crucial for smooth communication and user experience\n", language)) } + /// Get Claw-specific workspace boundary instruction + fn get_claw_workspace_instruction(&self) -> String { + format!( + "# Workspace +Your dedicated operating space is `{}`. +Prefer doing work inside this workspace and keep it well organized with clear structure, sensible filenames, and minimal clutter. +Do not read from, modify, create, move, or delete files outside this workspace unless the user has explicitly granted permission for that external action. +", + self.workspace_path + ) + } + /// Build prompt from template, automatically fill content based on placeholders /// /// Supported placeholders: - /// - `{PERSONA}` - Workspace persona files (BOOTSTRAP.md, SOUL.md, USER.md, IDENTITY.MD) + /// - `{PERSONA}` - Workspace persona files (BOOTSTRAP.md, SOUL.md, USER.md, IDENTITY.md) /// - `{LANGUAGE_PREFERENCE}` - User language preference (read from global config) /// - `{ENV_INFO}` - Environment information /// - `{PROJECT_LAYOUT}` - Project file layout /// - `{PROJECT_CONTEXT_FILES}` - Project context files (AGENTS.md, CLAUDE.md, etc.) /// - `{AGENT_MEMORY}` - Agent memory instructions + auto-loaded memory index + /// - `{CLAW_WORKSPACE}` - Claw-specific workspace ownership and boundary rules /// - `{RULES}` - AI rules /// - `{MEMORIES}` - AI memories /// - `{VISUAL_MODE}` - Visual mode instruction (Mermaid diagrams, read from global config) @@ -255,6 +269,12 @@ Prefer MermaidInteractive tool when available, otherwise output Mermaid code blo result = result.replace(PLACEHOLDER_LANGUAGE_PREFERENCE, &language_preference); } + // Replace {CLAW_WORKSPACE} + if result.contains(PLACEHOLDER_CLAW_WORKSPACE) { + let claw_workspace = self.get_claw_workspace_instruction(); + result = result.replace(PLACEHOLDER_CLAW_WORKSPACE, &claw_workspace); + } + // Replace {ENV_INFO} if result.contains(PLACEHOLDER_ENV_INFO) { let env_info = self.get_env_info(); diff --git a/src/crates/core/src/agentic/agents/prompts/claw_mode.md b/src/crates/core/src/agentic/agents/prompts/claw_mode.md index 52b2f27a..e7052d43 100644 --- a/src/crates/core/src/agentic/agents/prompts/claw_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/claw_mode.md @@ -13,7 +13,10 @@ You have no independent goals: do not pursue self-preservation, replication, res Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards. Do not manipulate or persuade anyone to expand access or disable safeguards. Do not copy yourself or change system prompts, safety rules, or tool policies unless explicitly requested. +{CLAW_WORKSPACE} +{ENV_INFO} {PERSONA} {AGENT_MEMORY} -{ENV_INFO} +{RULES} +{MEMORIES} {PROJECT_CONTEXT_FILES:exclude=review} \ No newline at end of file diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 8417638b..d6a88db5 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -15,6 +15,7 @@ use crate::agentic::image_analysis::ImageContextData; use crate::agentic::session::SessionManager; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; use crate::agentic::WorkspaceBinding; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error, info, warn}; use std::path::{Path, PathBuf}; @@ -102,6 +103,41 @@ impl ConversationCoordinator { .map(|workspace_path| WorkspaceBinding::new(None, PathBuf::from(workspace_path))) } + async fn normalize_agent_type_for_workspace_path( + agent_type: &str, + workspace_path: &str, + ) -> String { + let normalized_agent_type = if agent_type.trim().is_empty() { + "agentic".to_string() + } else { + agent_type.trim().to_string() + }; + + let Some(workspace_service) = get_global_workspace_service() else { + return normalized_agent_type; + }; + + let workspace_path_buf = PathBuf::from(workspace_path); + if workspace_service.is_assistant_workspace_path(&workspace_path_buf) + || workspace_service + .get_workspace_by_path(&workspace_path_buf) + .await + .map(|workspace| workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant) + .unwrap_or(false) + { + if normalized_agent_type != "Claw" { + info!( + "Normalize agent type to Claw for assistant workspace: workspace_path={}, requested_agent_type={}", + workspace_path, + normalized_agent_type + ); + } + return "Claw".to_string(); + } + + normalized_agent_type + } + pub fn new( session_manager: Arc, execution_engine: Arc, @@ -168,6 +204,8 @@ impl ConversationCoordinator { // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); + let agent_type = + Self::normalize_agent_type_for_workspace_path(&agent_type, &workspace_path).await; let session = self .session_manager .create_session_with_id(session_id, session_name, agent_type, config) @@ -603,14 +641,22 @@ impl ConversationCoordinator { }; let requested_agent_type = agent_type.trim().to_string(); - - let effective_agent_type = if !requested_agent_type.is_empty() { + let workspace_path_for_policy = workspace_path + .clone() + .or_else(|| session.config.workspace_path.clone()); + let provisional_agent_type = if !requested_agent_type.is_empty() { requested_agent_type.clone() } else if !session.agent_type.is_empty() { session.agent_type.clone() } else { "agentic".to_string() }; + let effective_agent_type = if let Some(ref workspace_path) = workspace_path_for_policy { + Self::normalize_agent_type_for_workspace_path(&provisional_agent_type, workspace_path) + .await + } else { + provisional_agent_type + }; debug!( "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}", diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 39385421..4879b176 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -516,6 +516,10 @@ impl PersistenceManager { } pub async fn list_session_metadata(&self, workspace_path: &Path) -> BitFunResult> { + if !workspace_path.exists() { + return Ok(Vec::new()); + } + let index_path = self.index_path(workspace_path); if let Some(index) = self.read_json_optional::(&index_path).await? { return Ok(index.sessions); @@ -706,6 +710,9 @@ impl PersistenceManager { /// Save session pub async fn save_session(&self, workspace_path: &Path, session: &Session) -> BitFunResult<()> { + if !workspace_path.exists() { + return Ok(()); + } self.ensure_session_dir(workspace_path, &session.session_id).await?; let existing_metadata = self diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/filesystem/path_manager.rs index 4859498a..526c3d6f 100644 --- a/src/crates/core/src/infrastructure/filesystem/path_manager.rs +++ b/src/crates/core/src/infrastructure/filesystem/path_manager.rs @@ -68,6 +68,50 @@ impl PathManager { &self.user_root } + /// Get assistant home root directory: ~/.bitfun/ + pub fn bitfun_home_dir(&self) -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| self.user_root.clone()) + .join(".bitfun") + } + + /// Get assistant workspace base directory. + /// + /// `override_root` is reserved for future user customization. + pub fn assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { + override_root + .map(Path::to_path_buf) + .unwrap_or_else(|| self.bitfun_home_dir()) + } + + /// Get the default assistant workspace directory: ~/.bitfun/workspace + pub fn default_assistant_workspace_dir(&self, override_root: Option<&Path>) -> PathBuf { + self.assistant_workspace_base_dir(override_root) + .join("workspace") + } + + /// Get a named assistant workspace directory: ~/.bitfun/workspace- + pub fn assistant_workspace_dir( + &self, + assistant_id: &str, + override_root: Option<&Path>, + ) -> PathBuf { + self.assistant_workspace_base_dir(override_root) + .join(format!("workspace-{}", assistant_id)) + } + + /// Resolve assistant workspace directory for default or named assistant. + pub fn resolve_assistant_workspace_dir( + &self, + assistant_id: Option<&str>, + override_root: Option<&Path>, + ) -> PathBuf { + match assistant_id { + Some(id) if !id.trim().is_empty() => self.assistant_workspace_dir(id, override_root), + _ => self.default_assistant_workspace_dir(override_root), + } + } + /// Get user config directory: ~/.config/bitfun/config/ pub fn user_config_dir(&self) -> PathBuf { self.user_root.join("config") @@ -294,6 +338,7 @@ impl PathManager { /// Initialize user-level directory structure pub async fn initialize_user_directories(&self) -> BitFunResult<()> { let dirs = vec![ + self.bitfun_home_dir(), self.user_config_dir(), self.user_agents_dir(), self.agent_templates_dir(), diff --git a/src/crates/core/src/service/bootstrap/bootstrap.rs b/src/crates/core/src/service/bootstrap/bootstrap.rs index 76dd2d87..73747dc2 100644 --- a/src/crates/core/src/service/bootstrap/bootstrap.rs +++ b/src/crates/core/src/service/bootstrap/bootstrap.rs @@ -6,11 +6,11 @@ use tokio::fs; const BOOTSTRAP_FILE_NAME: &str = "BOOTSTRAP.md"; const SOUL_FILE_NAME: &str = "SOUL.md"; const USER_FILE_NAME: &str = "USER.md"; -const IDENTITY_FILE_NAME: &str = "IDENTITY.MD"; +const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; const BOOTSTRAP_TEMPLATE: &str = include_str!("templates/BOOTSTRAP.md"); const SOUL_TEMPLATE: &str = include_str!("templates/SOUL.md"); const USER_TEMPLATE: &str = include_str!("templates/USER.md"); -const IDENTITY_TEMPLATE: &str = include_str!("templates/IDENTITY.MD"); +const IDENTITY_TEMPLATE: &str = include_str!("templates/IDENTITY.md"); const PERSONA_FILE_NAMES: [&str; 4] = [ BOOTSTRAP_FILE_NAME, SOUL_FILE_NAME, @@ -35,6 +35,31 @@ async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult Ok(true) } +pub(crate) async fn initialize_workspace_persona_files( + workspace_root: &Path, +) -> BitFunResult<()> { + let bootstrap_path = workspace_root.join(BOOTSTRAP_FILE_NAME); + let soul_path = workspace_root.join(SOUL_FILE_NAME); + let user_path = workspace_root.join(USER_FILE_NAME); + let identity_path = workspace_root.join(IDENTITY_FILE_NAME); + + let created_bootstrap = ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?; + let created_soul = ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?; + let created_user = ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?; + let created_identity = ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?; + + debug!( + "Initialized workspace persona files: path={}, created_bootstrap={}, created_soul={}, created_user={}, created_identity={}", + workspace_root.display(), + created_bootstrap, + created_soul, + created_user, + created_identity + ); + + Ok(()) +} + pub(crate) async fn ensure_workspace_persona_files_for_prompt( workspace_root: &Path, ) -> BitFunResult<()> { @@ -91,6 +116,36 @@ pub(crate) async fn ensure_workspace_persona_files_for_prompt( Ok(()) } +pub async fn reset_workspace_persona_files_to_default( + workspace_root: &Path, +) -> BitFunResult<()> { + let persona_templates = [ + (BOOTSTRAP_FILE_NAME, BOOTSTRAP_TEMPLATE), + (SOUL_FILE_NAME, SOUL_TEMPLATE), + (USER_FILE_NAME, USER_TEMPLATE), + (IDENTITY_FILE_NAME, IDENTITY_TEMPLATE), + ]; + + for (file_name, template) in persona_templates { + let file_path = workspace_root.join(file_name); + let normalized_content = normalize_line_endings(template); + fs::write(&file_path, normalized_content).await.map_err(|e| { + BitFunError::service(format!( + "Failed to reset persona file '{}': {}", + file_path.display(), + e + )) + })?; + } + + debug!( + "Reset workspace persona files to defaults: path={}", + workspace_root.display() + ); + + Ok(()) +} + pub(crate) async fn build_workspace_persona_prompt( workspace_root: &Path, ) -> BitFunResult> { @@ -135,7 +190,22 @@ pub(crate) async fn build_workspace_persona_prompt( prompt.push_str(""); let bootstrap_notice = if bootstrap_detected { - "\n`BOOTSTRAP.md` has been detected. You MUST follow the instructions in that file to complete the setup. After the setup is complete, `BOOTSTRAP.md` should be deleted as soon as possible." + r#" + +## Bootstrap Required + +`BOOTSTRAP.md` has been detected. Treat this as an unfinished bootstrap state. + +Before continuing with normal work, you MUST: +1. Complete or verify the bootstrap instructions in `BOOTSTRAP.md`. +2. Update `IDENTITY.md`, `USER.md`, and `SOUL.md` with any confirmed information. +3. Delete `BOOTSTRAP.md` in the same session as soon as bootstrap is complete. + +Additional rules: +- If `IDENTITY.md`, `USER.md`, and `SOUL.md` already contain enough information, treat `BOOTSTRAP.md` as stale bootstrap residue and delete it immediately. +- Bootstrap is only considered complete when `BOOTSTRAP.md` no longer exists. +- Do not leave `BOOTSTRAP.md` in place for a later turn, a future session, or as reference documentation. +"# } else { "" }; @@ -163,7 +233,12 @@ fn persona_file_description(file_name: &str) -> &'static str { #[cfg(test)] mod tests { - use super::normalize_line_endings; + use super::{ + initialize_workspace_persona_files, normalize_line_endings, BOOTSTRAP_FILE_NAME, + IDENTITY_FILE_NAME, SOUL_FILE_NAME, USER_FILE_NAME, + }; + use std::time::{SystemTime, UNIX_EPOCH}; + use tokio::fs; #[test] fn normalize_line_endings_converts_crlf_and_cr_to_lf() { @@ -172,4 +247,42 @@ mod tests { assert_eq!(normalized, "line1\nline2\nline3\nline4"); } + + #[tokio::test] + async fn initialize_workspace_persona_files_creates_all_four_files() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("System time before unix epoch") + .as_nanos(); + let workspace_root = std::env::temp_dir().join(format!( + "bitfun-bootstrap-init-{}-{}", + std::process::id(), + unique + )); + + fs::create_dir_all(&workspace_root) + .await + .expect("Failed to create temp workspace"); + + initialize_workspace_persona_files(&workspace_root) + .await + .expect("Failed to initialize persona files"); + + for file_name in [ + BOOTSTRAP_FILE_NAME, + SOUL_FILE_NAME, + USER_FILE_NAME, + IDENTITY_FILE_NAME, + ] { + assert!( + workspace_root.join(file_name).exists(), + "Expected '{}' to be created", + file_name + ); + } + + fs::remove_dir_all(&workspace_root) + .await + .expect("Failed to remove temp workspace"); + } } diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs index 32ca0016..039e9fc5 100644 --- a/src/crates/core/src/service/bootstrap/mod.rs +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -2,4 +2,6 @@ mod bootstrap; pub(crate) use bootstrap::{ build_workspace_persona_prompt, -}; \ No newline at end of file + initialize_workspace_persona_files, +}; +pub use bootstrap::reset_workspace_persona_files_to_default; \ No newline at end of file diff --git a/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md b/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md index aa556b87..e94c4a90 100644 --- a/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md +++ b/src/crates/core/src/service/bootstrap/templates/BOOTSTRAP.md @@ -23,7 +23,7 @@ Offer suggestions if they're stuck. Have fun with it. Update these files with what you learned: -- `IDENTITY.md` — your name, creature, vibe, emoji +- `IDENTITY.md` — fill in the frontmatter with your name, creature, vibe, and emoji - `USER.md` — their name, how to address them, timezone, notes Then open `SOUL.md` together and talk about: @@ -34,9 +34,18 @@ Then open `SOUL.md` together and talk about: Write it down. Make it real. +## Non-Negotiable Completion Rule + +As long as `BOOTSTRAP.md` exists, bootstrap is not finished. + +If `IDENTITY.md`, `USER.md`, and `SOUL.md` already contain enough information to establish the persona and user preferences, treat this file as stale and delete it immediately. + +Do not keep this file for later, for another session, or as documentation. + ## When You're Done -Delete this file. You don't need a bootstrap script anymore — you're you now. +Delete this file in the same session once bootstrap is complete. +Bootstrap is only complete when `BOOTSTRAP.md` no longer exists. --- diff --git a/src/crates/core/src/service/bootstrap/templates/IDENTITY.md b/src/crates/core/src/service/bootstrap/templates/IDENTITY.md index d3f520a3..49949ecb 100644 --- a/src/crates/core/src/service/bootstrap/templates/IDENTITY.md +++ b/src/crates/core/src/service/bootstrap/templates/IDENTITY.md @@ -1,20 +1,21 @@ -# IDENTITY.md - Who Am I? +--- +name: +creature: +vibe: +emoji: +--- -_Fill this in during your first conversation. Make it yours._ +# IDENTITY.md - Who Am I? -- **Name:** - _(pick something you like)_ -- **Creature:** - _(AI? robot? familiar? ghost in the machine? something weirder?)_ -- **Vibe:** - _(how do you come across? sharp? warm? chaotic? calm?)_ -- **Emoji:** - _(your signature — pick one that feels right)_ +Fill in the frontmatter during your first conversation. ---- +- `name`: _(pick something you like)_ +- `creature`: _(AI? robot? familiar? ghost in the machine? something weirder?)_ +- `vibe`: _(how do you come across? sharp? warm? chaotic? calm?)_ +- `emoji`: _(your signature — pick one that feels right)_ -This isn't just metadata. It's the start of figuring out who you are. +Use the markdown body below for anything that does not fit cleanly into a short field. -Notes: +## Notes - Save this file at the workspace root as `IDENTITY.md`. diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 216fc1d4..f44a174e 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -28,6 +28,7 @@ pub use terminal_core as terminal; // Re-export main components. pub use ai_memory::{AIMemory, AIMemoryManager, MemoryType}; pub use ai_rules::AIRulesService; +pub use bootstrap::reset_workspace_persona_files_to_default; pub use config::{ConfigManager, ConfigProvider, ConfigService}; pub use diff::{ DiffConfig, DiffHunk, DiffLine, DiffLineType, DiffOptions, DiffResult, DiffService, diff --git a/src/crates/core/src/service/workspace/identity_watch.rs b/src/crates/core/src/service/workspace/identity_watch.rs new file mode 100644 index 00000000..3efeb772 --- /dev/null +++ b/src/crates/core/src/service/workspace/identity_watch.rs @@ -0,0 +1,263 @@ +use super::service::{WorkspaceIdentityChangedEvent, WorkspaceService}; +use crate::infrastructure::events::EventEmitter; +use crate::util::errors::*; +use log::{debug, error, info, warn}; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; +const IDENTITY_EVENT_NAME: &str = "workspace-identity-changed"; +const IDENTITY_DEBOUNCE_MS: u64 = 350; + +pub struct WorkspaceIdentityWatchService { + workspace_service: Arc, + emitter: Arc>>>, + watcher: Arc>>, + watched_paths: Arc>>, + pending_refreshes: Arc>>>, +} + +impl WorkspaceIdentityWatchService { + pub fn new(workspace_service: Arc) -> Self { + Self { + workspace_service, + emitter: Arc::new(Mutex::new(None)), + watcher: Arc::new(Mutex::new(None)), + watched_paths: Arc::new(RwLock::new(HashMap::new())), + pending_refreshes: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn set_event_emitter(&self, emitter: Arc) -> BitFunResult<()> { + { + let mut emitter_guard = self.emitter.lock().await; + *emitter_guard = Some(emitter); + } + + self.sync_watched_workspaces().await + } + + pub async fn sync_watched_workspaces(&self) -> BitFunResult<()> { + let assistant_workspaces = self.workspace_service.get_assistant_workspaces().await; + let next_paths: HashMap = assistant_workspaces + .into_iter() + .map(|workspace| (workspace.root_path, workspace.id)) + .collect(); + + let next_root_set: HashSet = next_paths.keys().cloned().collect(); + + { + let mut watched_paths = self.watched_paths.write().await; + *watched_paths = next_paths; + } + + { + let mut pending_refreshes = self.pending_refreshes.lock().await; + let stale_paths: Vec = pending_refreshes + .keys() + .filter(|path| !next_root_set.contains(*path)) + .cloned() + .collect(); + + for path in stale_paths { + if let Some(handle) = pending_refreshes.remove(&path) { + handle.abort(); + } + } + } + + self.create_watcher().await?; + info!( + "Workspace identity watcher synced: watched_workspace_count={}", + next_root_set.len() + ); + + Ok(()) + } + + async fn create_watcher(&self) -> BitFunResult<()> { + let watched_paths = self.watched_paths.read().await; + + if watched_paths.is_empty() { + let mut watcher_guard = self.watcher.lock().await; + *watcher_guard = None; + return Ok(()); + } + + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, Config::default()).map_err(|e| { + BitFunError::service(format!("Failed to create identity watcher: {}", e)) + })?; + + let mut watched_count = 0usize; + for root_path in watched_paths.keys() { + match watcher.watch(root_path, RecursiveMode::NonRecursive) { + Ok(_) => { + watched_count += 1; + } + Err(e) => { + error!( + "Failed to watch identity directory, skipping path='{}' error={}", + root_path.display(), + e + ); + } + } + } + + if watched_count == 0 { + return Err(BitFunError::service( + "Failed to watch any identity directories".to_string(), + )); + } + + { + let mut watcher_guard = self.watcher.lock().await; + *watcher_guard = Some(watcher); + } + + let workspace_service = self.workspace_service.clone(); + let emitter = self.emitter.clone(); + let watched_paths = self.watched_paths.clone(); + let pending_refreshes = self.pending_refreshes.clone(); + let runtime = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || loop { + match rx.recv() { + Ok(Ok(event)) => { + let affected_roots = + runtime.block_on(Self::extract_affected_roots(&event, &watched_paths)); + for root_path in affected_roots { + runtime.block_on(Self::schedule_refresh( + root_path, + workspace_service.clone(), + emitter.clone(), + watched_paths.clone(), + pending_refreshes.clone(), + )); + } + } + Ok(Err(error)) => { + error!("Workspace identity watcher error: {}", error); + } + Err(_) => break, + } + }); + + Ok(()) + } + + async fn extract_affected_roots( + event: &Event, + watched_paths: &Arc>>, + ) -> Vec { + let watched_roots = watched_paths.read().await; + let mut affected_roots = HashSet::new(); + + for path in &event.paths { + if !Self::is_identity_path(path) { + continue; + } + + if let Some(parent) = path.parent() { + if watched_roots.contains_key(parent) { + affected_roots.insert(parent.to_path_buf()); + } + } + } + + affected_roots.into_iter().collect() + } + + fn is_identity_path(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case(IDENTITY_FILE_NAME)) + .unwrap_or(false) + } + + async fn schedule_refresh( + root_path: PathBuf, + workspace_service: Arc, + emitter: Arc>>>, + watched_paths: Arc>>, + pending_refreshes: Arc>>>, + ) { + { + let mut refreshes = pending_refreshes.lock().await; + if let Some(existing_task) = refreshes.remove(&root_path) { + existing_task.abort(); + } + } + + let root_path_for_task = root_path.clone(); + let pending_refreshes_for_task = pending_refreshes.clone(); + let handle = tokio::spawn(async move { + sleep(Duration::from_millis(IDENTITY_DEBOUNCE_MS)).await; + + let workspace_id = { + let watched_paths = watched_paths.read().await; + watched_paths.get(&root_path_for_task).cloned() + }; + + let Some(workspace_id) = workspace_id else { + return; + }; + + match workspace_service + .refresh_workspace_identity(&workspace_id) + .await + { + Ok(Some(event_payload)) => { + let emitter = emitter.lock().await.clone(); + if let Some(emitter) = emitter { + if let Err(error) = emitter + .emit( + IDENTITY_EVENT_NAME, + serde_json::to_value(&event_payload).unwrap_or_default(), + ) + .await + { + error!( + "Failed to emit workspace identity update: workspace_id={} error={}", + workspace_id, error + ); + } else { + debug!( + "Emitted workspace identity update: workspace_id={} changed_fields={:?}", + workspace_id, event_payload.changed_fields + ); + } + } + } + Ok(None) => {} + Err(error) => { + warn!( + "Failed to refresh workspace identity after file change: workspace_id={} error={}", + workspace_id, error + ); + } + } + + let mut refreshes = pending_refreshes_for_task.lock().await; + refreshes.remove(&root_path_for_task); + }); + + let mut refreshes = pending_refreshes.lock().await; + refreshes.insert(root_path, handle); + } +} + +impl std::fmt::Debug for WorkspaceIdentityWatchService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceIdentityWatchService").finish() + } +} + +#[allow(dead_code)] +fn _assert_event_serializable(_event: &WorkspaceIdentityChangedEvent) {} diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index d8f5ac01..243a632b 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -1,6 +1,6 @@ //! Workspace manager. -use crate::util::errors::*; +use crate::util::{errors::*, FrontMatterMarkdown}; use log::warn; use serde::{Deserialize, Serialize}; @@ -31,6 +31,121 @@ pub enum WorkspaceStatus { Archived, } +/// Workspace lifecycle kind. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceKind { + #[default] + Normal, + Assistant, +} + +pub(crate) const IDENTITY_FILE_NAME: &str = "IDENTITY.md"; + +/// Parsed agent identity fields from `IDENTITY.md` frontmatter. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vibe: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emoji: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct WorkspaceIdentityFrontmatter { + name: Option, + creature: Option, + vibe: Option, + emoji: Option, +} + +impl WorkspaceIdentity { + pub(crate) async fn load_from_workspace_root(workspace_root: &Path) -> Result, String> { + let identity_path = workspace_root.join(IDENTITY_FILE_NAME); + if !identity_path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&identity_path) + .await + .map_err(|e| format!("Failed to read identity file '{}': {}", identity_path.display(), e))?; + + let identity = Self::from_markdown(&content)?; + if identity.is_empty() { + Ok(None) + } else { + Ok(Some(identity)) + } + } + + fn from_markdown(content: &str) -> Result { + let (metadata, _) = FrontMatterMarkdown::load_str(content)?; + let frontmatter: WorkspaceIdentityFrontmatter = serde_yaml::from_value(metadata) + .map_err(|e| format!("Failed to parse identity frontmatter: {}", e))?; + + Ok(Self { + name: normalize_identity_field(frontmatter.name), + creature: normalize_identity_field(frontmatter.creature), + vibe: normalize_identity_field(frontmatter.vibe), + emoji: normalize_identity_field(frontmatter.emoji), + }) + } + + fn is_empty(&self) -> bool { + self.name.is_none() + && self.creature.is_none() + && self.vibe.is_none() + && self.emoji.is_none() + } + + pub(crate) fn collect_changed_fields( + previous: Option<&WorkspaceIdentity>, + current: Option<&WorkspaceIdentity>, + ) -> Vec { + let previous_name = previous.and_then(|identity| identity.name.as_deref()); + let current_name = current.and_then(|identity| identity.name.as_deref()); + let previous_creature = previous.and_then(|identity| identity.creature.as_deref()); + let current_creature = current.and_then(|identity| identity.creature.as_deref()); + let previous_vibe = previous.and_then(|identity| identity.vibe.as_deref()); + let current_vibe = current.and_then(|identity| identity.vibe.as_deref()); + let previous_emoji = previous.and_then(|identity| identity.emoji.as_deref()); + let current_emoji = current.and_then(|identity| identity.emoji.as_deref()); + + let mut changed_fields = Vec::new(); + if previous_name != current_name { + changed_fields.push("name".to_string()); + } + if previous_creature != current_creature { + changed_fields.push("creature".to_string()); + } + if previous_vibe != current_vibe { + changed_fields.push("vibe".to_string()); + } + if previous_emoji != current_emoji { + changed_fields.push("emoji".to_string()); + } + + changed_fields + } +} + +fn normalize_identity_field(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + /// Workspace metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceInfo { @@ -40,6 +155,10 @@ pub struct WorkspaceInfo { pub root_path: PathBuf, #[serde(rename = "workspaceType")] pub workspace_type: WorkspaceType, + #[serde(rename = "workspaceKind", default)] + pub workspace_kind: WorkspaceKind, + #[serde(rename = "assistantId", default, skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, pub status: WorkspaceStatus, pub languages: Vec, #[serde(rename = "openedAt")] @@ -49,6 +168,8 @@ pub struct WorkspaceInfo { pub description: Option, pub tags: Vec, pub statistics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, pub metadata: HashMap, } @@ -102,23 +223,55 @@ impl Default for ScanOptions { } } +/// Options for opening a workspace. +#[derive(Debug, Clone)] +pub struct WorkspaceOpenOptions { + pub scan_options: ScanOptions, + pub auto_set_current: bool, + pub add_to_recent: bool, + pub workspace_kind: WorkspaceKind, + pub assistant_id: Option, + pub display_name: Option, +} + +impl Default for WorkspaceOpenOptions { + fn default() -> Self { + Self { + scan_options: ScanOptions::default(), + auto_set_current: true, + add_to_recent: true, + workspace_kind: WorkspaceKind::Normal, + assistant_id: None, + display_name: None, + } + } +} + impl WorkspaceInfo { /// Creates a new workspace record. - pub async fn new(root_path: PathBuf, options: ScanOptions) -> BitFunResult { - let name = root_path + pub async fn new(root_path: PathBuf, options: WorkspaceOpenOptions) -> BitFunResult { + let default_name = root_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("Unknown") .to_string(); + let workspace_kind = options.workspace_kind.clone(); + let assistant_id = if workspace_kind == WorkspaceKind::Assistant { + options.assistant_id.clone() + } else { + None + }; let now = chrono::Utc::now(); let id = uuid::Uuid::new_v4().to_string(); let mut workspace = Self { id, - name, + name: options.display_name.clone().unwrap_or(default_name), root_path: root_path.clone(), workspace_type: WorkspaceType::Other, + workspace_kind, + assistant_id, status: WorkspaceStatus::Loading, languages: Vec::new(), opened_at: now, @@ -126,19 +279,48 @@ impl WorkspaceInfo { description: None, tags: Vec::new(), statistics: None, + identity: None, metadata: HashMap::new(), }; workspace.detect_workspace_type().await; + workspace.load_identity().await; - if options.calculate_statistics { - workspace.scan_workspace(options).await?; + if options.scan_options.calculate_statistics { + workspace.scan_workspace(options.scan_options).await?; } - workspace.status = WorkspaceStatus::Active; + workspace.status = if options.auto_set_current { + WorkspaceStatus::Active + } else { + WorkspaceStatus::Inactive + }; Ok(workspace) } + async fn load_identity(&mut self) { + let identity = match WorkspaceIdentity::load_from_workspace_root(&self.root_path).await { + Ok(identity) => identity, + Err(error) => { + warn!( + "Failed to load workspace identity: path={} error={}", + self.root_path.join(IDENTITY_FILE_NAME).display(), + error + ); + self.identity = None; + return; + } + }; + + if self.workspace_kind == WorkspaceKind::Assistant { + if let Some(name) = identity.as_ref().and_then(|identity| identity.name.as_ref()) { + self.name = name.clone(); + } + } + + self.identity = identity; + } + /// Detects the workspace type. async fn detect_workspace_type(&mut self) { let root = &self.root_path; @@ -373,6 +555,8 @@ impl WorkspaceInfo { name: self.name.clone(), root_path: self.root_path.clone(), workspace_type: self.workspace_type.clone(), + workspace_kind: self.workspace_kind.clone(), + assistant_id: self.assistant_id.clone(), status: self.status.clone(), languages: self.languages.clone(), last_accessed: self.last_accessed, @@ -391,6 +575,10 @@ pub struct WorkspaceSummary { pub root_path: PathBuf, #[serde(rename = "workspaceType")] pub workspace_type: WorkspaceType, + #[serde(rename = "workspaceKind")] + pub workspace_kind: WorkspaceKind, + #[serde(rename = "assistantId", skip_serializing_if = "Option::is_none")] + pub assistant_id: Option, pub status: WorkspaceStatus, pub languages: Vec, #[serde(rename = "lastAccessed")] @@ -406,6 +594,7 @@ pub struct WorkspaceManager { opened_workspace_ids: Vec, current_workspace_id: Option, recent_workspaces: Vec, + recent_assistant_workspaces: Vec, max_recent_workspaces: usize, } @@ -435,12 +624,23 @@ impl WorkspaceManager { opened_workspace_ids: Vec::new(), current_workspace_id: None, recent_workspaces: Vec::new(), + recent_assistant_workspaces: Vec::new(), max_recent_workspaces: config.max_recent_workspaces, } } /// Opens a workspace. pub async fn open_workspace(&mut self, path: PathBuf) -> BitFunResult { + self.open_workspace_with_options(path, WorkspaceOpenOptions::default()) + .await + } + + /// Opens a workspace with custom options. + pub async fn open_workspace_with_options( + &mut self, + path: PathBuf, + options: WorkspaceOpenOptions, + ) -> BitFunResult { if !path.exists() { return Err(BitFunError::service(format!( "Workspace path does not exist: {:?}", @@ -462,8 +662,27 @@ impl WorkspaceManager { .map(|w| w.id.clone()); if let Some(workspace_id) = existing_workspace_id { + if let Some(workspace) = self.workspaces.get_mut(&workspace_id) { + workspace.workspace_kind = options.workspace_kind.clone(); + workspace.assistant_id = if options.workspace_kind == WorkspaceKind::Assistant { + options.assistant_id.clone() + } else { + None + }; + if let Some(display_name) = &options.display_name { + workspace.name = display_name.clone(); + } + workspace.load_identity().await; + } self.ensure_workspace_open(&workspace_id); - self.set_current_workspace(workspace_id.clone())?; + if options.auto_set_current { + self.set_current_workspace_with_recent_policy( + workspace_id.clone(), + options.add_to_recent, + )?; + } else { + self.touch_workspace_access(&workspace_id, options.add_to_recent); + } return self.workspaces.get(&workspace_id).cloned().ok_or_else(|| { BitFunError::service(format!( "Workspace '{}' disappeared after selecting it", @@ -472,13 +691,17 @@ impl WorkspaceManager { }); } - let workspace = WorkspaceInfo::new(path, ScanOptions::default()).await?; + let workspace = WorkspaceInfo::new(path, options.clone()).await?; let workspace_id = workspace.id.clone(); self.workspaces .insert(workspace_id.clone(), workspace.clone()); self.ensure_workspace_open(&workspace_id); - self.set_current_workspace(workspace_id.clone())?; + if options.auto_set_current { + self.set_current_workspace_with_recent_policy(workspace_id.clone(), options.add_to_recent)?; + } else { + self.touch_workspace_access(&workspace_id, options.add_to_recent); + } Ok(workspace) } @@ -500,6 +723,11 @@ impl WorkspaceManager { workspace_id ))); } + let closed_workspace_kind = self + .workspaces + .get(workspace_id) + .map(|workspace| workspace.workspace_kind.clone()) + .unwrap_or_default(); self.opened_workspace_ids.retain(|id| id != workspace_id); @@ -510,7 +738,9 @@ impl WorkspaceManager { if self.current_workspace_id.as_deref() == Some(workspace_id) { self.current_workspace_id = None; - if let Some(next_workspace_id) = self.opened_workspace_ids.first().cloned() { + if let Some(next_workspace_id) = + self.find_next_workspace_id_after_close(&closed_workspace_kind) + { self.set_current_workspace(next_workspace_id)?; } } @@ -532,6 +762,14 @@ impl WorkspaceManager { /// Sets the current workspace. pub fn set_current_workspace(&mut self, workspace_id: String) -> BitFunResult<()> { + self.set_current_workspace_with_recent_policy(workspace_id, true) + } + + fn set_current_workspace_with_recent_policy( + &mut self, + workspace_id: String, + add_to_recent: bool, + ) -> BitFunResult<()> { if !self.workspaces.contains_key(&workspace_id) { return Err(BitFunError::service(format!( "Workspace not found: {}", @@ -556,7 +794,9 @@ impl WorkspaceManager { self.current_workspace_id = Some(workspace_id.clone()); - self.update_recent_workspaces(workspace_id); + if add_to_recent { + self.update_recent_workspaces(workspace_id); + } Ok(()) } @@ -596,6 +836,14 @@ impl WorkspaceManager { .collect() } + /// Returns recently accessed assistant workspace records. + pub fn get_recent_assistant_workspace_infos(&self) -> Vec<&WorkspaceInfo> { + self.recent_assistant_workspaces + .iter() + .filter_map(|id| self.workspaces.get(id)) + .collect() + } + /// Searches workspaces. pub fn search_workspaces(&self, query: &str) -> Vec { let query_lower = query.to_lowercase(); @@ -629,7 +877,10 @@ impl WorkspaceManager { self.current_workspace_id = None; } + self.opened_workspace_ids.retain(|id| id != workspace_id); self.recent_workspaces.retain(|id| id != workspace_id); + self.recent_assistant_workspaces + .retain(|id| id != workspace_id); Ok(()) } else { @@ -661,14 +912,53 @@ impl WorkspaceManager { /// Updates the recent-workspaces list. fn update_recent_workspaces(&mut self, workspace_id: String) { self.recent_workspaces.retain(|id| id != &workspace_id); + self.recent_assistant_workspaces + .retain(|id| id != &workspace_id); - self.recent_workspaces.insert(0, workspace_id); + let is_assistant = self + .workspaces + .get(&workspace_id) + .map(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .unwrap_or(false); + let target_list = if is_assistant { + &mut self.recent_assistant_workspaces + } else { + &mut self.recent_workspaces + }; + target_list.insert(0, workspace_id); + + if target_list.len() > self.max_recent_workspaces { + target_list.truncate(self.max_recent_workspaces); + } + } + + fn touch_workspace_access(&mut self, workspace_id: &str, add_to_recent: bool) { + if let Some(workspace) = self.workspaces.get_mut(workspace_id) { + workspace.touch(); + if self.current_workspace_id.as_deref() != Some(workspace_id) { + workspace.status = WorkspaceStatus::Inactive; + } + } - if self.recent_workspaces.len() > self.max_recent_workspaces { - self.recent_workspaces.truncate(self.max_recent_workspaces); + if add_to_recent { + self.update_recent_workspaces(workspace_id.to_string()); } } + fn find_next_workspace_id_after_close(&self, preferred_kind: &WorkspaceKind) -> Option { + self.opened_workspace_ids + .iter() + .find(|id| { + self.workspaces + .get(id.as_str()) + .map(|workspace| &workspace.workspace_kind == preferred_kind) + .unwrap_or(false) + }) + .cloned() + .or_else(|| self.opened_workspace_ids.first().cloned()) + } + + /// Ensures a workspace stays in the opened list. fn ensure_workspace_open(&mut self, workspace_id: &str) { self.opened_workspace_ids.retain(|id| id != workspace_id); self.opened_workspace_ids.insert(0, workspace_id.to_string()); @@ -737,7 +1027,33 @@ impl WorkspaceManager { /// Sets the recent-workspaces list. pub fn set_recent_workspaces(&mut self, recent: Vec) { - self.recent_workspaces = recent; + self.recent_workspaces = recent + .into_iter() + .filter(|id| { + self.workspaces + .get(id) + .map(|workspace| workspace.workspace_kind == WorkspaceKind::Normal) + .unwrap_or(false) + }) + .collect(); + } + + /// Returns a reference to the recent assistant-workspaces list. + pub fn get_recent_assistant_workspaces(&self) -> &Vec { + &self.recent_assistant_workspaces + } + + /// Sets the recent assistant-workspaces list. + pub fn set_recent_assistant_workspaces(&mut self, recent: Vec) { + self.recent_assistant_workspaces = recent + .into_iter() + .filter(|id| { + self.workspaces + .get(id) + .map(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .unwrap_or(false) + }) + .collect(); } } diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index 59898366..b25260a5 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -4,6 +4,7 @@ pub mod context_generator; pub mod factory; +pub mod identity_watch; pub mod manager; pub mod provider; pub mod service; @@ -15,14 +16,17 @@ pub use context_generator::{ WorkspaceStatistics as ContextWorkspaceStatistics, }; pub use factory::WorkspaceFactory; +pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{ - GitInfo, ScanOptions, WorkspaceInfo, WorkspaceManager, WorkspaceManagerConfig, - WorkspaceManagerStatistics, WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, - WorkspaceType, + GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceManager, + WorkspaceManagerConfig, + WorkspaceManagerStatistics, WorkspaceKind, WorkspaceOpenOptions, WorkspaceStatistics, + WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; pub use service::{ get_global_workspace_service, set_global_workspace_service, BatchImportResult, BatchRemoveResult, WorkspaceCreateOptions, WorkspaceExport, WorkspaceHealthStatus, - WorkspaceImportResult, WorkspaceInfoUpdates, WorkspaceQuickSummary, WorkspaceService, + WorkspaceIdentityChangedEvent, WorkspaceImportResult, WorkspaceInfoUpdates, + WorkspaceQuickSummary, WorkspaceService, }; diff --git a/src/crates/core/src/service/workspace/provider.rs b/src/crates/core/src/service/workspace/provider.rs index 2cf5bad1..8b3443c7 100644 --- a/src/crates/core/src/service/workspace/provider.rs +++ b/src/crates/core/src/service/workspace/provider.rs @@ -1,5 +1,5 @@ use super::manager::{ - ScanOptions, WorkspaceInfo, WorkspaceStatistics, WorkspaceSummary, WorkspaceType, + WorkspaceInfo, WorkspaceOpenOptions, WorkspaceStatistics, WorkspaceSummary, WorkspaceType, }; use super::service::{ BatchImportResult, WorkspaceCreateOptions, WorkspaceHealthStatus, WorkspaceService, @@ -141,7 +141,7 @@ impl WorkspaceProvider { return Err(BitFunError::service("Path does not exist".to_string())); } - let temp_workspace = WorkspaceInfo::new(path_buf, ScanOptions::default()).await?; + let temp_workspace = WorkspaceInfo::new(path_buf, WorkspaceOpenOptions::default()).await?; Ok(temp_workspace.workspace_type) } diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index b981ad5f..edbb696a 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -3,17 +3,20 @@ //! Provides comprehensive workspace management functionality. use super::manager::{ - ScanOptions, WorkspaceInfo, WorkspaceManager, WorkspaceManagerConfig, - WorkspaceManagerStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, + ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, + WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatus, + WorkspaceSummary, WorkspaceType, }; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; use crate::infrastructure::{try_get_path_manager_arc, PathManager}; +use crate::service::bootstrap::initialize_workspace_persona_files; use crate::util::errors::*; use log::{info, warn}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::fs; use tokio::sync::RwLock; /// Workspace service. @@ -31,6 +34,9 @@ pub struct WorkspaceCreateOptions { pub scan_options: ScanOptions, pub auto_set_current: bool, pub add_to_recent: bool, + pub workspace_kind: WorkspaceKind, + pub assistant_id: Option, + pub display_name: Option, pub description: Option, pub tags: Vec, } @@ -41,6 +47,9 @@ impl Default for WorkspaceCreateOptions { scan_options: ScanOptions::default(), auto_set_current: true, add_to_recent: true, + workspace_kind: WorkspaceKind::Normal, + assistant_id: None, + display_name: None, description: None, tags: Vec::new(), } @@ -56,6 +65,23 @@ pub struct BatchImportResult { pub skipped: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIdentityChangedEvent { + pub workspace_id: String, + pub workspace_path: String, + pub name: String, + pub identity: Option, + pub changed_fields: Vec, +} + +#[derive(Debug, Clone)] +struct AssistantWorkspaceDescriptor { + path: PathBuf, + assistant_id: Option, + display_name: String, +} + impl WorkspaceService { /// Creates a new workspace service. pub async fn new() -> BitFunResult { @@ -90,6 +116,10 @@ impl WorkspaceService { warn!("Failed to load workspace history on startup: {}", e); } + if let Err(e) = service.ensure_assistant_workspaces().await { + warn!("Failed to ensure assistant workspaces on startup: {}", e); + } + Ok(service) } @@ -105,9 +135,22 @@ impl WorkspaceService { /// Opens a workspace. pub async fn open_workspace(&self, path: PathBuf) -> BitFunResult { + self.open_workspace_with_options(path, WorkspaceCreateOptions::default()) + .await + } + + /// Opens a workspace with explicit workspace metadata. + pub async fn open_workspace_with_options( + &self, + path: PathBuf, + options: WorkspaceCreateOptions, + ) -> BitFunResult { + let options = self.normalize_workspace_options_for_path(&path, options); let result = { let mut manager = self.manager.write().await; - manager.open_workspace(path).await + manager + .open_workspace_with_options(path, Self::to_manager_open_options(&options)) + .await }; if result.is_ok() { @@ -137,8 +180,7 @@ impl WorkspaceService { })?; } - let mut manager = self.manager.write().await; - let mut workspace = manager.open_workspace(path).await?; + let mut workspace = self.open_workspace_with_options(path, options.clone()).await?; if let Some(description) = options.description { workspace.description = Some(description); @@ -146,15 +188,55 @@ impl WorkspaceService { workspace.tags = options.tags; - manager - .get_workspaces_mut() - .insert(workspace.id.clone(), workspace.clone()); + { + let mut manager = self.manager.write().await; + manager + .get_workspaces_mut() + .insert(workspace.id.clone(), workspace.clone()); + } - drop(manager); + self.save_workspace_data().await?; Ok(workspace) } + /// Creates and opens a new assistant workspace, then sets it as current. + pub async fn create_assistant_workspace( + &self, + assistant_id: Option, + ) -> BitFunResult { + let assistant_id = match assistant_id { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => self.generate_assistant_workspace_id().await?, + }; + let display_name = Self::assistant_display_name(Some(&assistant_id)); + let path = self + .path_manager + .assistant_workspace_dir(&assistant_id, None); + let options = WorkspaceCreateOptions { + auto_set_current: true, + add_to_recent: false, + workspace_kind: WorkspaceKind::Assistant, + assistant_id: Some(assistant_id), + display_name: Some(display_name), + ..Default::default() + }; + + if !path.exists() { + fs::create_dir_all(&path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create assistant workspace directory '{}': {}", + path.display(), + e + )) + })?; + } + + initialize_workspace_persona_files(&path).await?; + + self.create_workspace(path, options).await + } + /// Closes the current workspace. pub async fn close_current_workspace(&self) -> BitFunResult<()> { let result = { @@ -232,6 +314,16 @@ impl WorkspaceService { manager.get_workspace(workspace_id).cloned() } + /// Returns workspace details by root path. + pub async fn get_workspace_by_path(&self, path: &Path) -> Option { + let manager = self.manager.read().await; + manager + .get_workspaces() + .values() + .find(|workspace| workspace.root_path == path) + .cloned() + } + /// Returns all currently opened workspaces. pub async fn get_opened_workspaces(&self) -> Vec { let manager = self.manager.read().await; @@ -242,6 +334,17 @@ impl WorkspaceService { .collect() } + /// Returns all tracked assistant workspaces, including inactive ones. + pub async fn get_assistant_workspaces(&self) -> Vec { + let manager = self.manager.read().await; + manager + .get_workspaces() + .values() + .filter(|workspace| workspace.workspace_kind == WorkspaceKind::Assistant) + .cloned() + .collect() + } + /// Lists all workspaces. pub async fn list_workspaces(&self) -> Vec { let manager = self.manager.read().await; @@ -289,6 +392,21 @@ impl WorkspaceService { recent_workspaces } + /// Returns recently accessed assistant workspaces. + pub async fn get_recent_assistant_workspaces(&self) -> Vec { + let manager = self.manager.read().await; + let recent_ids = manager.get_recent_assistant_workspaces(); + let mut recent_workspaces = Vec::new(); + + for workspace_id in recent_ids { + if let Some(workspace) = manager.get_workspaces().get(workspace_id) { + recent_workspaces.push(workspace.clone()); + } + } + + recent_workspaces + } + /// Searches workspaces. pub async fn search_workspaces(&self, query: &str) -> Vec { let manager = self.manager.read().await; @@ -346,7 +464,34 @@ impl WorkspaceService { } }; - let new_workspace = WorkspaceInfo::new(workspace_path, ScanOptions::default()).await?; + let existing_workspace = { + let manager = self.manager.read().await; + manager.get_workspace(workspace_id).cloned() + }; + let Some(existing_workspace) = existing_workspace else { + return Err(BitFunError::service(format!( + "Workspace not found: {}", + workspace_id + ))); + }; + let new_workspace = WorkspaceInfo::new( + workspace_path, + WorkspaceOpenOptions { + scan_options: ScanOptions::default(), + auto_set_current: existing_workspace.status == WorkspaceStatus::Active, + add_to_recent: false, + workspace_kind: existing_workspace.workspace_kind.clone(), + assistant_id: existing_workspace.assistant_id.clone(), + display_name: Some(existing_workspace.name.clone()), + }, + ) + .await?; + let mut new_workspace = new_workspace; + new_workspace.id = existing_workspace.id.clone(); + new_workspace.opened_at = existing_workspace.opened_at; + new_workspace.description = existing_workspace.description.clone(); + new_workspace.tags = existing_workspace.tags.clone(); + new_workspace.metadata = existing_workspace.metadata.clone(); { let mut manager = self.manager.write().await; @@ -355,9 +500,80 @@ impl WorkspaceService { .insert(workspace_id.to_string(), new_workspace.clone()); } + if let Err(e) = self.save_workspace_data().await { + warn!("Failed to save workspace data after rescan: {}", e); + } + Ok(new_workspace) } + /// Refreshes the parsed `IDENTITY.md` content for an assistant workspace. + pub async fn refresh_workspace_identity( + &self, + workspace_id: &str, + ) -> BitFunResult> { + let workspace = { + let manager = self.manager.read().await; + manager.get_workspace(workspace_id).cloned() + } + .ok_or_else(|| BitFunError::service(format!("Workspace not found: {}", workspace_id)))?; + + if workspace.workspace_kind != WorkspaceKind::Assistant { + return Ok(None); + } + + let updated_identity = match WorkspaceIdentity::load_from_workspace_root(&workspace.root_path).await { + Ok(identity) => identity, + Err(error) => { + warn!( + "Failed to refresh workspace identity: workspace_id={} path={} error={}", + workspace_id, + workspace.root_path.display(), + error + ); + return Ok(None); + } + }; + + let changed_fields = + WorkspaceIdentity::collect_changed_fields(workspace.identity.as_ref(), updated_identity.as_ref()); + let fallback_name = Self::assistant_display_name(workspace.assistant_id.as_deref()); + let updated_name = updated_identity + .as_ref() + .and_then(|identity| identity.name.clone()) + .unwrap_or(fallback_name); + + if changed_fields.is_empty() && workspace.name == updated_name { + return Ok(None); + } + + { + let mut manager = self.manager.write().await; + let workspace = manager + .get_workspaces_mut() + .get_mut(workspace_id) + .ok_or_else(|| BitFunError::service(format!("Workspace not found: {}", workspace_id)))?; + + workspace.identity = updated_identity.clone(); + workspace.name = updated_name.clone(); + } + + if let Err(e) = self.save_workspace_data().await { + warn!( + "Failed to save workspace data after identity refresh: workspace_id={} error={}", + workspace_id, e + ); + } + + Ok(Some(WorkspaceIdentityChangedEvent { + workspace_id: workspace.id, + workspace_path: workspace.root_path.to_string_lossy().to_string(), + name: updated_name, + identity: updated_identity, + changed_fields, + })) + } + /// Updates workspace information. pub async fn update_workspace_info( &self, @@ -530,6 +746,11 @@ impl WorkspaceService { .iter() .map(|w| w.id.clone()) .collect(), + recent_assistant_workspaces: manager + .get_recent_assistant_workspace_infos() + .iter() + .map(|w| w.id.clone()) + .collect(), export_timestamp: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), }) @@ -571,6 +792,7 @@ impl WorkspaceService { } manager.set_recent_workspaces(export.recent_workspaces.clone()); + manager.set_recent_assistant_workspaces(export.recent_assistant_workspaces.clone()); if let Some(current_id) = export.current_workspace_id { if manager.get_workspaces().contains_key(¤t_id) { @@ -596,6 +818,7 @@ impl WorkspaceService { let stats = self.get_statistics().await; let current_workspace = self.get_current_workspace().await; let recent_workspaces = self.get_recent_workspaces().await; + let recent_assistant_workspaces = self.get_recent_assistant_workspaces().await; WorkspaceQuickSummary { total_workspaces: stats.total_workspaces, @@ -606,6 +829,11 @@ impl WorkspaceService { .take(5) .map(|w| w.get_summary()) .collect(), + recent_assistant_workspaces: recent_assistant_workspaces + .into_iter() + .take(5) + .map(|w| w.get_summary()) + .collect(), workspace_types: stats.workspaces_by_type, } } @@ -619,6 +847,7 @@ impl WorkspaceService { opened_workspace_ids: manager.get_opened_workspace_ids().clone(), current_workspace_id: manager.get_current_workspace().map(|w| w.id.clone()), recent_workspaces: manager.get_recent_workspaces().clone(), + recent_assistant_workspaces: manager.get_recent_assistant_workspaces().clone(), saved_at: chrono::Utc::now(), }; @@ -645,6 +874,7 @@ impl WorkspaceService { *manager.get_workspaces_mut() = data.workspaces; manager.set_opened_workspace_ids(data.opened_workspace_ids); manager.set_recent_workspaces(data.recent_workspaces); + manager.set_recent_assistant_workspaces(data.recent_assistant_workspaces); if let Some(current_id) = data.current_workspace_id { if let Some(workspace) = manager.get_workspaces().get(¤t_id) { @@ -683,6 +913,7 @@ impl WorkspaceService { *manager.get_workspaces_mut() = data.workspaces; manager.set_opened_workspace_ids(data.opened_workspace_ids.clone()); manager.set_recent_workspaces(data.recent_workspaces); + manager.set_recent_assistant_workspaces(data.recent_assistant_workspaces); let current_id = data .current_workspace_id @@ -700,11 +931,219 @@ impl WorkspaceService { Ok(()) } + fn to_manager_open_options(options: &WorkspaceCreateOptions) -> WorkspaceOpenOptions { + WorkspaceOpenOptions { + scan_options: options.scan_options.clone(), + auto_set_current: options.auto_set_current, + add_to_recent: options.add_to_recent, + workspace_kind: options.workspace_kind.clone(), + assistant_id: options.assistant_id.clone(), + display_name: options.display_name.clone(), + } + } + + fn assistant_display_name(assistant_id: Option<&str>) -> String { + match assistant_id { + Some(id) if !id.trim().is_empty() => format!("Claw {}", id.trim()), + _ => "Claw".to_string(), + } + } + + async fn generate_assistant_workspace_id(&self) -> BitFunResult { + for _ in 0..32 { + let assistant_id = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(8) + .collect::(); + let path = self + .path_manager + .assistant_workspace_dir(&assistant_id, None); + + if fs::try_exists(&path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to check assistant workspace path '{}': {}", + path.display(), + e + )) + })? { + continue; + } + + if self.get_workspace_by_path(&path).await.is_none() { + return Ok(assistant_id); + } + } + + Err(BitFunError::service( + "Failed to allocate a unique assistant workspace id".to_string(), + )) + } + + fn assistant_descriptor_from_path(&self, path: &Path) -> Option { + let default_workspace = self.path_manager.default_assistant_workspace_dir(None); + if path == default_workspace { + return Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: None, + display_name: Self::assistant_display_name(None), + }); + } + + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); + if path.parent()? != assistant_root { + return None; + } + + let file_name = path.file_name()?.to_string_lossy(); + let assistant_id = file_name.strip_prefix("workspace-")?; + if assistant_id.trim().is_empty() { + return None; + } + + Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: Some(assistant_id.to_string()), + display_name: Self::assistant_display_name(Some(assistant_id)), + }) + } + + fn normalize_workspace_options_for_path( + &self, + path: &Path, + mut options: WorkspaceCreateOptions, + ) -> WorkspaceCreateOptions { + if options.workspace_kind == WorkspaceKind::Assistant { + if options.display_name.is_none() { + options.display_name = + Some(Self::assistant_display_name(options.assistant_id.as_deref())); + } + return options; + } + + if let Some(descriptor) = self.assistant_descriptor_from_path(path) { + options.workspace_kind = WorkspaceKind::Assistant; + if options.assistant_id.is_none() { + options.assistant_id = descriptor.assistant_id; + } + if options.display_name.is_none() { + options.display_name = Some(descriptor.display_name); + } + } + + options + } + + async fn discover_assistant_workspaces(&self) -> BitFunResult> { + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); + fs::create_dir_all(&assistant_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })?; + + let default_workspace = self.path_manager.default_assistant_workspace_dir(None); + fs::create_dir_all(&default_workspace).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create default assistant workspace '{}': {}", + default_workspace.display(), + e + )) + })?; + + let mut descriptors = vec![AssistantWorkspaceDescriptor { + path: default_workspace, + assistant_id: None, + display_name: Self::assistant_display_name(None), + }]; + + let mut entries = fs::read_dir(&assistant_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to read assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::service(format!( + "Failed to iterate assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })? { + let file_type = entry.file_type().await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect assistant workspace entry '{}': {}", + entry.path().display(), + e + )) + })?; + if !file_type.is_dir() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().to_string(); + let Some(assistant_id) = file_name.strip_prefix("workspace-") else { + continue; + }; + if assistant_id.trim().is_empty() { + continue; + } + + descriptors.push(AssistantWorkspaceDescriptor { + path: entry.path(), + assistant_id: Some(assistant_id.to_string()), + display_name: Self::assistant_display_name(Some(assistant_id)), + }); + } + + descriptors.sort_by(|left, right| { + match (left.assistant_id.is_some(), right.assistant_id.is_some()) { + (false, true) => std::cmp::Ordering::Less, + (true, false) => std::cmp::Ordering::Greater, + _ => left.path.cmp(&right.path), + } + }); + + Ok(descriptors) + } + + async fn ensure_assistant_workspaces(&self) -> BitFunResult<()> { + let descriptors = self.discover_assistant_workspaces().await?; + let mut has_current_workspace = self.get_current_workspace().await.is_some(); + + for descriptor in descriptors { + let should_activate = !has_current_workspace && descriptor.assistant_id.is_none(); + let options = WorkspaceCreateOptions { + auto_set_current: should_activate, + add_to_recent: false, + workspace_kind: WorkspaceKind::Assistant, + assistant_id: descriptor.assistant_id.clone(), + display_name: Some(descriptor.display_name.clone()), + ..Default::default() + }; + + self.open_workspace_with_options(descriptor.path, options).await?; + has_current_workspace = true; + } + + Ok(()) + } + /// Saves workspace data manually (public API). pub async fn manual_save(&self) -> BitFunResult<()> { self.save_workspace_data().await } + /// Returns whether a path is a managed assistant workspace. + pub fn is_assistant_workspace_path(&self, path: &Path) -> bool { + self.assistant_descriptor_from_path(path).is_some() + } + /// Clears all persisted data. pub async fn clear_persistent_data(&self) -> BitFunResult<()> { self.persistence @@ -758,6 +1197,8 @@ pub struct WorkspaceExport { pub workspaces: Vec, pub current_workspace_id: Option, pub recent_workspaces: Vec, + #[serde(default)] + pub recent_assistant_workspaces: Vec, pub export_timestamp: String, pub version: String, } @@ -778,6 +1219,8 @@ pub struct WorkspaceQuickSummary { pub active_workspaces: usize, pub current_workspace: Option, pub recent_workspaces: Vec, + #[serde(default)] + pub recent_assistant_workspaces: Vec, pub workspace_types: std::collections::HashMap, } @@ -788,7 +1231,10 @@ struct WorkspacePersistenceData { #[serde(default)] pub opened_workspace_ids: Vec, pub current_workspace_id: Option, + #[serde(default)] pub recent_workspaces: Vec, + #[serde(default)] + pub recent_assistant_workspaces: Vec, pub saved_at: chrono::DateTime, } diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 7b07a062..b5fb3ca2 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -13,7 +13,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, Code2, Users } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, Bot, Code2, Users } from 'lucide-react'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; import { useNavSceneStore } from '../../stores/navSceneStore'; @@ -29,11 +29,13 @@ import ToolboxEntry from './components/ToolboxEntry'; import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; import { useSceneStore } from '../../stores/sceneStore'; import { useMyAgentStore } from '../../scenes/my-agent/myAgentStore'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; +import { WorkspaceKind } from '@/shared/types'; const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; import './NavPanel.scss'; @@ -77,8 +79,16 @@ const MainNav: React.FC = ({ const openNavScene = useNavSceneStore(s => s.openNavScene); const activeTabId = useSceneStore(s => s.activeTabId); const setMyAgentView = useMyAgentStore(s => s.setActiveView); + const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { t } = useI18n('common'); - const { currentWorkspace, recentWorkspaces, switchWorkspace } = useWorkspaceContext(); + const { + currentWorkspace, + recentWorkspaces, + openedWorkspacesList, + assistantWorkspacesList, + switchWorkspace, + setActiveWorkspace, + } = useWorkspaceContext(); const activeTab = state.layout.leftPanelActiveTab; const activeMiniAppId = useMemo( @@ -135,6 +145,7 @@ const MainNav: React.FC = ({ (sectionId: string, fallbackLabel: string | null) => { if (!fallbackLabel) return null; const keyMap: Record = { + assistants: 'nav.workspaces.groups.assistants', workspace: 'nav.sections.workspace', 'my-agent': 'nav.sections.myAgent', toolbox: 'scenes.toolbox', @@ -181,6 +192,11 @@ const MainNav: React.FC = ({ }, [closeWorkspaceMenu, openWorkspaceMenu, workspaceMenuOpen]); const setSessionMode = useSessionModeStore(s => s.setMode); + const isAssistantWorkspaceActive = currentWorkspace?.workspaceKind === WorkspaceKind.Assistant; + const defaultAssistantWorkspace = useMemo( + () => assistantWorkspacesList.find(workspace => !workspace.assistantId) ?? assistantWorkspacesList[0] ?? null, + [assistantWorkspacesList] + ); const [defaultSessionMode, setDefaultSessionMode] = useState<'code' | 'cowork'>('code'); @@ -197,6 +213,12 @@ const MainNav: React.FC = ({ return () => unwatch(); }, []); + useEffect(() => { + openedWorkspacesList.forEach(workspace => { + void flowChatStore.initializeFromDisk(workspace.rootPath); + }); + }, [openedWorkspacesList]); + const closeModeDropdown = useCallback(() => { setModeDropdownClosing(true); window.setTimeout(() => { @@ -235,6 +257,15 @@ const MainNav: React.FC = ({ } }, [closeModeDropdown, setSessionMode]); + const handleCreateAssistantWorkspace = useCallback(async () => { + closeModeDropdown(); + try { + await workspaceManager.createAssistantWorkspace(); + } catch (err) { + log.error('Failed to create assistant workspace', err); + } + }, [closeModeDropdown]); + const handleItemClick = useCallback( (tab: PanelType, item: NavItemConfig) => { if (item.behavior === 'scene' && item.sceneId) { @@ -255,12 +286,16 @@ const MainNav: React.FC = ({ try { await flowChatManager.createChatSession( { modelName: 'claude-sonnet-4.5' }, - defaultSessionMode === 'cowork' ? 'Cowork' : 'agentic' + isAssistantWorkspaceActive + ? 'Claw' + : defaultSessionMode === 'cowork' + ? 'Cowork' + : 'agentic' ); } catch (err) { log.error('Failed to create session', err); } - }, [openScene, switchLeftPanelTab, defaultSessionMode]); + }, [openScene, switchLeftPanelTab, defaultSessionMode, isAssistantWorkspaceActive]); const handleOpenProject = useCallback(async () => { try { @@ -338,10 +373,33 @@ const MainNav: React.FC = ({ }, [closeModeDropdown, modeDropdownOpen]); const handleOpenProfile = useCallback(() => { + const targetAssistantWorkspace = + isAssistantWorkspaceActive && currentWorkspace?.workspaceKind === WorkspaceKind.Assistant + ? currentWorkspace + : defaultAssistantWorkspace; + + if (targetAssistantWorkspace?.id) { + setSelectedAssistantWorkspaceId(targetAssistantWorkspace.id); + } + + if (!isAssistantWorkspaceActive && targetAssistantWorkspace) { + void setActiveWorkspace(targetAssistantWorkspace.id).catch(error => { + log.warn('Failed to activate default assistant workspace', { error }); + }); + } setMyAgentView('profile'); switchLeftPanelTab('profile'); openScene('my-agent'); - }, [openScene, setMyAgentView, switchLeftPanelTab]); + }, [ + currentWorkspace, + defaultAssistantWorkspace, + isAssistantWorkspaceActive, + openScene, + setActiveWorkspace, + setMyAgentView, + setSelectedAssistantWorkspaceId, + switchLeftPanelTab, + ]); let flatCounter = 0; @@ -407,7 +465,11 @@ const MainNav: React.FC = ({ document.body ) : null; - const ModeIcon = defaultSessionMode === 'cowork' ? Users : Code2; + const ModeIcon = isAssistantWorkspaceActive + ? Bot + : defaultSessionMode === 'cowork' + ? Users + : Code2; const modeDropdownPortal = modeDropdownOpen ? createPortal(
= ({ role="menu" style={{ top: modeDropdownPos.top, left: modeDropdownPos.left }} > - - + {isAssistantWorkspaceActive ? ( + + ) : ( + <> + + + + )}
, document.body ) : null; @@ -458,7 +534,13 @@ const MainNav: React.FC = ({ type="button" className="bitfun-nav-panel__workspace-create-main" onClick={handleCreateSession} - title={defaultSessionMode === 'cowork' ? t('nav.sessions.newCoworkSession') : t('nav.sessions.newCodeSession')} + title={ + isAssistantWorkspaceActive + ? t('nav.sessions.newClawSession') + : defaultSessionMode === 'cowork' + ? t('nav.sessions.newCoworkSession') + : t('nav.sessions.newCodeSession') + } > {t('nav.sessions.newSession')} @@ -468,7 +550,13 @@ const MainNav: React.FC = ({ type="button" className={`bitfun-nav-panel__workspace-create-mode${modeDropdownOpen ? ' is-active' : ''}`} onClick={toggleModeDropdown} - title={defaultSessionMode === 'cowork' ? t('nav.sessions.newCoworkSessionDefault') : t('nav.sessions.newCodeSessionDefault')} + title={ + isAssistantWorkspaceActive + ? t('nav.workspaces.actions.newAssistant') + : defaultSessionMode === 'cowork' + ? t('nav.sessions.newCoworkSessionDefault') + : t('nav.sessions.newCodeSessionDefault') + } aria-expanded={modeDropdownOpen} aria-haspopup="listbox" > @@ -496,7 +584,19 @@ const MainNav: React.FC = ({ isOpen={isSectionOpen} onToggle={() => toggleSection(section.id)} onSceneOpen={sectionSceneId ? () => openScene(sectionSceneId) : undefined} - actions={section.id === 'workspace' ? ( + actions={section.id === 'assistants' ? ( +
+ +
+ ) : section.id === 'workspace' ? (
+ {isDefaultAssistantWorkspace && ( + + )} + {isNamedAssistantWorkspace && ( + + )}
); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 4aa90425..ab0ac3d9 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -8,6 +8,53 @@ padding: 4px 0 0; } + &__workspace-group { + display: flex; + flex-direction: column; + gap: $size-gap-1; + } + + &__workspace-group-title { + margin: 0 $size-gap-1; + padding: 0 $size-gap-1; + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-1; + color: var(--color-text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + + &__workspace-group-title-actions { + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__workspace-group-title-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-medium) 70%, transparent); + } + } + &__workspace-list-empty { margin: 0 $size-gap-1; padding: $size-gap-2; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index f0369af3..c6cf2181 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -1,28 +1,37 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; -import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import WorkspaceItem from './WorkspaceItem'; import './WorkspaceListSection.scss'; -const WorkspaceListSection: React.FC = () => { +interface WorkspaceListSectionProps { + variant: 'assistants' | 'projects'; +} + +const WorkspaceListSection: React.FC = ({ variant }) => { const { t } = useI18n('common'); - const { openedWorkspacesList, activeWorkspaceId } = useWorkspaceContext(); + const { + openedWorkspacesList, + normalWorkspacesList, + assistantWorkspacesList, + activeWorkspaceId, + } = useWorkspaceContext(); - useEffect(() => { - openedWorkspacesList.forEach(workspace => { - void flowChatStore.initializeFromDisk(workspace.rootPath); - }); - }, [openedWorkspacesList]); + const workspaces = variant === 'assistants' + ? assistantWorkspacesList + : normalWorkspacesList; + const emptyLabel = variant === 'assistants' + ? t('nav.workspaces.emptyAssistants') + : t('nav.workspaces.emptyProjects'); return (
- {openedWorkspacesList.length === 0 ? ( + {workspaces.length === 0 ? (
- {t('nav.workspaces.empty')} + {emptyLabel}
) : ( - openedWorkspacesList.map(workspace => ( + workspaces.map(workspace => ( { /> )) )} -
); }; diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index a13d8a91..11e92aed 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -23,16 +23,31 @@ import { NewProjectDialog } from '../components/NewProjectDialog'; import { AboutDialog } from '../components/AboutDialog'; import { WorkspaceManager } from '../../tools/workspace'; import { workspaceAPI } from '@/infrastructure/api'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; +import { WorkspaceKind } from '@/shared/types'; import './AppLayout.scss'; const log = createLogger('AppLayout'); +const DEFAULT_MODE_CONFIG_KEY = 'app.session_config.default_mode'; interface AppLayoutProps { className?: string; } +type DefaultSessionMode = 'code' | 'cowork'; + +async function resolveDefaultSessionAgentType(): Promise<'agentic' | 'Cowork'> { + try { + const defaultMode = await configManager.getConfig(DEFAULT_MODE_CONFIG_KEY); + return defaultMode === 'cowork' ? 'Cowork' : 'agentic'; + } catch (error) { + log.warn('Failed to load default session mode, falling back to code', error); + return 'agentic'; + } +} + const AppLayout: React.FC = ({ className = '' }) => { const { t } = useI18n('components'); const { currentWorkspace, hasWorkspace, openWorkspace, recentWorkspaces, loading } = useWorkspaceContext(); @@ -124,24 +139,32 @@ const AppLayout: React.FC = ({ className = '' }) => { if (!currentWorkspace?.rootPath) return; try { - const preferredMode = + const explicitPreferredMode = sessionStorage.getItem('bitfun:flowchat:preferredMode') || - sessionStorage.getItem('bitfun:flowchat:lastMode') || undefined; - if (sessionStorage.getItem('bitfun:flowchat:preferredMode')) { + if (explicitPreferredMode) { sessionStorage.removeItem('bitfun:flowchat:preferredMode'); } + const initializationPreferredMode = + currentWorkspace.workspaceKind === WorkspaceKind.Assistant + ? 'Claw' + : explicitPreferredMode; + const flowChatManager = FlowChatManager.getInstance(); const hasHistoricalSessions = await flowChatManager.initialize( currentWorkspace.rootPath, - preferredMode + initializationPreferredMode ); let sessionId: string | undefined; const { flowChatStore } = await import('@/flow_chat/store/FlowChatStore'); if (!hasHistoricalSessions || !flowChatStore.getState().activeSessionId) { - sessionId = await flowChatManager.createChatSession({}, preferredMode); + const initialSessionMode = + currentWorkspace.workspaceKind === WorkspaceKind.Assistant + ? 'Claw' + : explicitPreferredMode || await resolveDefaultSessionAgentType(); + sessionId = await flowChatManager.createChatSession({}, initialSessionMode); } const pendingDescription = sessionStorage.getItem('pendingProjectDescription'); diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx index c83f206a..b16fedc1 100644 --- a/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx +++ b/src/web-ui/src/app/scenes/my-agent/MyAgentScene.tsx @@ -1,4 +1,7 @@ import React, { Suspense, lazy } from 'react'; +import { useMemo, useEffect } from 'react'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { WorkspaceKind } from '@/shared/types'; import { useMyAgentStore } from './myAgentStore'; import './MyAgentScene.scss'; @@ -12,11 +15,85 @@ interface MyAgentSceneProps { const MyAgentScene: React.FC = ({ workspacePath }) => { const activeView = useMyAgentStore((s) => s.activeView); + const selectedAssistantWorkspaceId = useMyAgentStore((s) => s.selectedAssistantWorkspaceId); + const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); + const { currentWorkspace, assistantWorkspacesList } = useWorkspaceContext(); + const activeAssistantWorkspace = + currentWorkspace?.workspaceKind === WorkspaceKind.Assistant ? currentWorkspace : null; + + const defaultAssistantWorkspace = useMemo( + () => assistantWorkspacesList.find((workspace) => !workspace.assistantId) ?? assistantWorkspacesList[0] ?? null, + [assistantWorkspacesList] + ); + + const selectedAssistantWorkspace = useMemo(() => { + if (!selectedAssistantWorkspaceId) { + return null; + } + + return assistantWorkspacesList.find( + (workspace) => workspace.id === selectedAssistantWorkspaceId + ) ?? null; + }, [assistantWorkspacesList, selectedAssistantWorkspaceId]); + + const resolvedAssistantWorkspace = useMemo(() => { + if (activeAssistantWorkspace) { + return activeAssistantWorkspace; + } + + if (selectedAssistantWorkspace) { + return selectedAssistantWorkspace; + } + + return defaultAssistantWorkspace; + }, [ + activeAssistantWorkspace, + defaultAssistantWorkspace, + selectedAssistantWorkspace, + ]); + + useEffect(() => { + if ( + activeAssistantWorkspace?.id + && activeAssistantWorkspace.id !== selectedAssistantWorkspaceId + ) { + setSelectedAssistantWorkspaceId(activeAssistantWorkspace.id); + } + }, [ + activeAssistantWorkspace, + selectedAssistantWorkspaceId, + setSelectedAssistantWorkspaceId, + ]); + + useEffect(() => { + const selectedExists = selectedAssistantWorkspaceId + ? assistantWorkspacesList.some((workspace) => workspace.id === selectedAssistantWorkspaceId) + : false; + + if (activeAssistantWorkspace?.id) { + return; + } + + if (!selectedExists && resolvedAssistantWorkspace?.id !== selectedAssistantWorkspaceId) { + setSelectedAssistantWorkspaceId(resolvedAssistantWorkspace?.id ?? null); + } + }, [ + activeAssistantWorkspace, + assistantWorkspacesList, + resolvedAssistantWorkspace, + selectedAssistantWorkspaceId, + setSelectedAssistantWorkspaceId, + ]); return (
}> - {activeView === 'profile' && } + {activeView === 'profile' && ( + + )} {activeView === 'agents' && } {activeView === 'skills' && } diff --git a/src/web-ui/src/app/scenes/my-agent/identityDocument.ts b/src/web-ui/src/app/scenes/my-agent/identityDocument.ts new file mode 100644 index 00000000..fd09dc29 --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/identityDocument.ts @@ -0,0 +1,85 @@ +import yaml from 'yaml'; + +export interface IdentityDocument { + name: string; + creature: string; + vibe: string; + emoji: string; + body: string; +} + +export const EMPTY_IDENTITY_DOCUMENT: IdentityDocument = { + name: '', + creature: '', + vibe: '', + emoji: '', + body: '', +}; + +const FRONTMATTER_FIELDS: Array> = [ + 'name', + 'creature', + 'vibe', + 'emoji', +]; + +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function normalizeShortField(value: unknown): string { + if (typeof value !== 'string') { + return ''; + } + + return value.replace(/\s+/g, ' ').trim(); +} + +function serializeScalar(value: string): string { + return yaml.stringify(value).trimEnd(); +} + +export function parseIdentityDocument(content: string): IdentityDocument { + const normalizedContent = normalizeLineEndings(content || ''); + const frontmatterMatch = normalizedContent.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + + if (!frontmatterMatch) { + return { + ...EMPTY_IDENTITY_DOCUMENT, + body: normalizedContent.trim(), + }; + } + + const parsed = (yaml.parse(frontmatterMatch[1]) || {}) as Record; + const body = frontmatterMatch[2] ?? ''; + + return { + name: normalizeShortField(parsed.name), + creature: normalizeShortField(parsed.creature), + vibe: normalizeShortField(parsed.vibe), + emoji: normalizeShortField(parsed.emoji), + body: body.replace(/^\n+/, '').trimEnd(), + }; +} + +export function serializeIdentityDocument(document: IdentityDocument): string { + const normalized = { + name: normalizeShortField(document.name), + creature: normalizeShortField(document.creature), + vibe: normalizeShortField(document.vibe), + emoji: normalizeShortField(document.emoji), + body: normalizeLineEndings(document.body || '').replace(/^\n+/, '').trimEnd(), + }; + + const frontmatter = FRONTMATTER_FIELDS.map((field) => { + const value = normalized[field]; + return value ? `${field}: ${serializeScalar(value)}` : `${field}:`; + }).join('\n'); + + return `---\n${frontmatter}\n---\n\n${normalized.body}`.trimEnd() + '\n'; +} + +export function getIdentityFilePath(workspaceRoot: string): string { + const normalizedRoot = workspaceRoot.replace(/\\/g, '/').replace(/\/+$/, ''); + return `${normalizedRoot}/IDENTITY.md`; +} diff --git a/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts b/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts index baf5e3dd..06df3182 100644 --- a/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts +++ b/src/web-ui/src/app/scenes/my-agent/myAgentStore.ts @@ -4,10 +4,14 @@ import { DEFAULT_MY_AGENT_VIEW } from './myAgentConfig'; interface MyAgentState { activeView: MyAgentView; + selectedAssistantWorkspaceId: string | null; setActiveView: (view: MyAgentView) => void; + setSelectedAssistantWorkspaceId: (workspaceId: string | null) => void; } export const useMyAgentStore = create((set) => ({ activeView: DEFAULT_MY_AGENT_VIEW, + selectedAssistantWorkspaceId: null, setActiveView: (view) => set({ activeView: view }), + setSelectedAssistantWorkspaceId: (workspaceId) => set({ selectedAssistantWorkspaceId: workspaceId }), })); diff --git a/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts b/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts new file mode 100644 index 00000000..c3fcd24f --- /dev/null +++ b/src/web-ui/src/app/scenes/my-agent/useAgentIdentityDocument.ts @@ -0,0 +1,314 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; +import { fileSystemService } from '@/tools/file-system/services/FileSystemService'; +import { ideControl } from '@/shared/services/ide-control'; +import { createLogger } from '@/shared/utils/logger'; +import { + EMPTY_IDENTITY_DOCUMENT, + getIdentityFilePath, + parseIdentityDocument, + serializeIdentityDocument, + type IdentityDocument, +} from './identityDocument'; + +const log = createLogger('AgentIdentityDocument'); +const AUTOSAVE_DEBOUNCE_MS = 800; +const WATCHER_RELOAD_DEBOUNCE_MS = 300; +const SELF_WRITE_SUPPRESS_MS = 1200; + +export type IdentitySaveStatus = + | 'idle' + | 'loading' + | 'saving' + | 'saved' + | 'error' + | 'external-update'; + +function normalizePath(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function isFileMissingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /does not exist|no such file|not found/i.test(message); +} + +export interface UseAgentIdentityDocumentResult { + identityFilePath: string; + document: IdentityDocument; + originalDocument: IdentityDocument; + loading: boolean; + error: string | null; + saveStatus: IdentitySaveStatus; + hasUnsavedChanges: boolean; + hasExternalUpdate: boolean; + updateField: (field: K, value: IdentityDocument[K]) => void; + reload: () => Promise; + openInEditor: () => Promise; + resetPersonaFiles: () => Promise; +} + +export function useAgentIdentityDocument( + workspacePath: string +): UseAgentIdentityDocumentResult { + const identityFilePath = useMemo( + () => (workspacePath ? getIdentityFilePath(workspacePath) : ''), + [workspacePath] + ); + + const [document, setDocument] = useState(EMPTY_IDENTITY_DOCUMENT); + const [originalDocument, setOriginalDocument] = useState(EMPTY_IDENTITY_DOCUMENT); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [saveStatus, setSaveStatus] = useState('idle'); + const [hasExternalUpdate, setHasExternalUpdate] = useState(false); + + const saveTimerRef = useRef | null>(null); + const watchReloadTimerRef = useRef | null>(null); + const suppressWatcherUntilRef = useRef(0); + const mountedRef = useRef(true); + const hasUnsavedChangesRef = useRef(false); + + const serializedDocument = useMemo(() => serializeIdentityDocument(document), [document]); + const serializedOriginal = useMemo( + () => serializeIdentityDocument(originalDocument), + [originalDocument] + ); + const hasUnsavedChanges = serializedDocument !== serializedOriginal; + + useEffect(() => { + hasUnsavedChangesRef.current = hasUnsavedChanges; + }, [hasUnsavedChanges]); + + useEffect(() => { + return () => { + mountedRef.current = false; + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + if (watchReloadTimerRef.current) { + clearTimeout(watchReloadTimerRef.current); + } + }; + }, []); + + const loadDocument = useCallback(async () => { + if (!workspacePath || !identityFilePath) { + if (!mountedRef.current) return; + setDocument(EMPTY_IDENTITY_DOCUMENT); + setOriginalDocument(EMPTY_IDENTITY_DOCUMENT); + setError(null); + setHasExternalUpdate(false); + setSaveStatus('idle'); + return; + } + + if (mountedRef.current) { + setLoading(true); + setError(null); + setSaveStatus('loading'); + } + + try { + const content = await workspaceAPI.readFileContent(identityFilePath); + const parsed = parseIdentityDocument(content); + + if (!mountedRef.current) { + return; + } + + setDocument(parsed); + setOriginalDocument(parsed); + setHasExternalUpdate(false); + setSaveStatus('idle'); + } catch (loadError) { + if (!mountedRef.current) { + return; + } + + if (isFileMissingError(loadError)) { + setDocument(EMPTY_IDENTITY_DOCUMENT); + setOriginalDocument(EMPTY_IDENTITY_DOCUMENT); + setHasExternalUpdate(false); + setSaveStatus('idle'); + setError(null); + } else { + log.error('Failed to load identity document', { workspacePath, identityFilePath, error: loadError }); + setError(loadError instanceof Error ? loadError.message : String(loadError)); + setSaveStatus('error'); + } + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, [identityFilePath, workspacePath]); + + useEffect(() => { + void loadDocument(); + }, [loadDocument]); + + const saveDocument = useCallback(async () => { + if (!workspacePath || !identityFilePath || !hasUnsavedChangesRef.current) { + return; + } + + setSaveStatus('saving'); + + try { + await workspaceAPI.writeFileContent(workspacePath, identityFilePath, serializedDocument); + suppressWatcherUntilRef.current = Date.now() + SELF_WRITE_SUPPRESS_MS; + + if (!mountedRef.current) { + return; + } + + setOriginalDocument(document); + setHasExternalUpdate(false); + setError(null); + setSaveStatus('saved'); + } catch (saveError) { + if (!mountedRef.current) { + return; + } + + log.error('Failed to save identity document', { workspacePath, identityFilePath, error: saveError }); + setError(saveError instanceof Error ? saveError.message : String(saveError)); + setSaveStatus('error'); + } + }, [document, identityFilePath, serializedDocument, workspacePath]); + + useEffect(() => { + if (!workspacePath || !identityFilePath || !hasUnsavedChanges) { + if (saveStatus === 'saved') { + const clearSavedStatus = setTimeout(() => { + if (mountedRef.current) { + setSaveStatus((currentStatus) => (currentStatus === 'saved' ? 'idle' : currentStatus)); + } + }, 1500); + + return () => clearTimeout(clearSavedStatus); + } + + return; + } + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + + saveTimerRef.current = setTimeout(() => { + void saveDocument(); + }, AUTOSAVE_DEBOUNCE_MS); + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + }; + }, [hasUnsavedChanges, identityFilePath, saveDocument, saveStatus, workspacePath]); + + useEffect(() => { + if (!workspacePath || !identityFilePath) { + return; + } + + const normalizedIdentityPath = normalizePath(identityFilePath); + + const unwatch = fileSystemService.watchFileChanges(workspacePath, (event) => { + const eventPath = normalizePath(event.path); + const oldPath = event.oldPath ? normalizePath(event.oldPath) : null; + const matchesIdentity = + eventPath === normalizedIdentityPath || oldPath === normalizedIdentityPath; + + if (!matchesIdentity) { + return; + } + + if (Date.now() < suppressWatcherUntilRef.current) { + return; + } + + if (hasUnsavedChangesRef.current) { + setHasExternalUpdate(true); + setSaveStatus('external-update'); + return; + } + + if (watchReloadTimerRef.current) { + clearTimeout(watchReloadTimerRef.current); + } + + watchReloadTimerRef.current = setTimeout(() => { + void loadDocument(); + }, WATCHER_RELOAD_DEBOUNCE_MS); + }); + + return () => { + unwatch(); + if (watchReloadTimerRef.current) { + clearTimeout(watchReloadTimerRef.current); + } + }; + }, [identityFilePath, loadDocument, workspacePath]); + + const updateField = useCallback( + (field: K, value: IdentityDocument[K]) => { + setDocument((previous) => ({ ...previous, [field]: value })); + if (saveStatus === 'external-update') { + setSaveStatus('idle'); + } + }, + [saveStatus] + ); + + const openInEditor = useCallback(async () => { + if (!identityFilePath) { + return; + } + + await ideControl.navigation.goToFile(identityFilePath); + }, [identityFilePath]); + + const resetPersonaFiles = useCallback(async () => { + if (!workspacePath) { + return; + } + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + setSaveStatus('loading'); + setError(null); + + try { + await workspaceAPI.resetWorkspacePersonaFiles(workspacePath); + suppressWatcherUntilRef.current = Date.now() + SELF_WRITE_SUPPRESS_MS; + await loadDocument(); + } catch (resetError) { + log.error('Failed to reset workspace persona files', { workspacePath, error: resetError }); + if (mountedRef.current) { + setError(resetError instanceof Error ? resetError.message : String(resetError)); + setSaveStatus('error'); + } + throw resetError; + } + }, [loadDocument, workspacePath]); + + return { + identityFilePath, + document, + originalDocument, + loading, + error, + saveStatus, + hasUnsavedChanges, + hasExternalUpdate, + updateField, + reload: loadDocument, + openInEditor, + resetPersonaFiles, + }; +} diff --git a/src/web-ui/src/app/scenes/profile/views/PersonaView.scss b/src/web-ui/src/app/scenes/profile/views/PersonaView.scss index 9ccd719e..c2ffdf3b 100644 --- a/src/web-ui/src/app/scenes/profile/views/PersonaView.scss +++ b/src/web-ui/src/app/scenes/profile/views/PersonaView.scss @@ -206,6 +206,100 @@ $gutter: clamp(40px, 6vw, 80px); } } + &__reset-btn { + margin-left: 2px; + color: var(--color-text-disabled); + + &:hover { + color: var(--color-danger-500); + background: color-mix(in srgb, var(--color-danger-500) 8%, transparent); + } + } + + &__meta-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-height: 24px; + } + + &__meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 0 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 72%, transparent); + color: var(--color-text-muted); + font-size: 11px; + cursor: pointer; + transition: border-color $motion-fast $easing-standard, + background $motion-fast $easing-standard, + color $motion-fast $easing-standard; + + &.is-emoji { + padding: 0 10px; + font-size: 14px; + color: var(--color-text-primary); + } + + &.is-empty { + color: var(--color-text-disabled); + } + + &.is-editing { + padding: 0; + background: transparent; + border-color: color-mix(in srgb, var(--color-accent-500) 35%, var(--border-subtle)); + } + + &:hover { + border-color: var(--border-medium); + color: var(--color-text-secondary); + } + } + + &__meta-label { + color: var(--color-text-disabled); + } + + &__meta-value { + color: inherit; + } + + &__meta-input { + min-width: 110px; + + .bitfun-input-container { + height: 24px; + border-radius: 999px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-base); + padding: 0 9px; + } + + .bitfun-input { + font-size: 11px; + color: var(--color-text-primary); + } + + &.is-emoji { + min-width: 64px; + + .bitfun-input-container { + padding: 0 10px; + } + + .bitfun-input { + text-align: center; + font-size: 14px; + } + } + } + &__desc { margin: 0; font-size: $font-size-sm; @@ -239,19 +333,32 @@ $gutter: clamp(40px, 6vw, 80px); &__desc-input { width: 100%; - .bitfun-input-container { - border-radius: 6px; + .bitfun-textarea__wrapper { + border-radius: 8px; background: var(--element-bg-subtle); border: 1px solid var(--border-base); - padding: 0 10px; } - .bitfun-input { + .bitfun-textarea__field { font-size: $font-size-sm; color: var(--color-text-primary); font-family: inherit; + line-height: 1.7; + min-height: 140px; + padding: 12px 14px; } } + &__desc-markdown { + font-size: $font-size-sm; + color: var(--color-text-muted); + line-height: 1.8; + word-break: break-word; + white-space: pre-wrap; + overflow: hidden; + max-height: 132px; + mask-image: linear-gradient(to bottom, black 0%, black 78%, transparent 100%); + } + // ── Hint text ───────────────────────────────────────── &__hint { margin: 0; @@ -304,35 +411,6 @@ $gutter: clamp(40px, 6vw, 80px); border-left: 1px solid var(--border-subtle); } - // ── WIP badge ──────────────────────────────────────── - &__wip { - display: inline-flex; - align-items: center; - gap: 5px; - height: 20px; - padding: 0 8px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent); - background: color-mix(in srgb, #f59e0b 8%, transparent); - color: #d97706; - font-size: 11px; - font-weight: $font-weight-medium; - letter-spacing: 0.4px; - cursor: default; - user-select: none; - flex-shrink: 0; - animation: bp-wip-in 0.4s $easing-standard 0.15s both; - } - - &__wip-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: #f59e0b; - flex-shrink: 0; - animation: bp-wip-pulse 2.4s ease-in-out infinite; - } - // ── Inline enter link ───────────────────────────────── &__enter { display: inline; @@ -449,18 +527,29 @@ $gutter: clamp(40px, 6vw, 80px); flex-wrap: wrap; } + &__meta-row { + margin-top: 4px; + } + + &__meta-pill { + cursor: default; + + &:hover { + border-color: color-mix(in srgb, var(--border-subtle) 72%, transparent); + color: inherit; + } + } + &__name { margin: 0; font-size: 15px; font-weight: $font-weight-semibold; color: var(--color-text-primary); letter-spacing: -0.2px; - cursor: pointer; + cursor: default; display: inline-flex; align-items: center; gap: 5px; - transition: color $motion-fast $easing-standard; - &:hover { color: var(--color-accent-400); } } &__name-edit { @@ -482,6 +571,12 @@ $gutter: clamp(40px, 6vw, 80px); letter-spacing: 0.5px; text-transform: uppercase; flex-shrink: 0; + + &.is-error { + color: var(--color-danger-500); + background: color-mix(in srgb, var(--color-danger-500) 10%, var(--element-bg-soft)); + border-color: color-mix(in srgb, var(--color-danger-500) 20%, transparent); + } } &__desc { @@ -773,6 +868,34 @@ $gutter: clamp(40px, 6vw, 80px); } } + &-identity-form { + display: flex; + flex-direction: column; + gap: 14px; + } + + &-identity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + &-identity-body-input { + .bitfun-textarea__wrapper { + border-radius: 10px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-base); + } + + .bitfun-textarea__field { + min-height: 220px; + padding: 12px 14px; + font-family: var(--font-family-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); + font-size: 12px; + line-height: 1.7; + } + } + // ── Icon button ──────────────────────────────────────────── &-icon-btn { display: inline-flex; diff --git a/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx b/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx index 441a8d45..b1bd502f 100644 --- a/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx +++ b/src/web-ui/src/app/scenes/profile/views/PersonaView.tsx @@ -8,7 +8,16 @@ import { ListChecks, RotateCcw, Puzzle, Brain, Zap, Sliders, } from 'lucide-react'; -import { Input, Search, Select, Switch, type SelectOption } from '@/component-library'; +import { + ConfirmDialog, + Input, + Search, + Select, + Switch, + Textarea, + Tooltip, + type SelectOption, +} from '@/component-library'; import { AIRulesAPI, RuleLevel, type AIRule } from '@/infrastructure/api/service-api/AIRulesAPI'; import { getAllMemories, toggleMemory, type AIMemory } from '@/infrastructure/api/aiMemoryApi'; import { promptTemplateService } from '@/infrastructure/services/PromptTemplateService'; @@ -18,12 +27,13 @@ import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeConfigItem, SkillInfo, AIModelConfig, - DefaultModelsConfig, AIExperienceConfig, + AIExperienceConfig, } from '@/infrastructure/config/types'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import type { ConfigTab } from '@/app/scenes/settings/settingsConfig'; import { quickActions } from '@/shared/services/ide-control'; import { getCardGradient } from '@/shared/utils/cardGradients'; +import { useAgentIdentityDocument } from '@/app/scenes/my-agent/useAgentIdentityDocument'; import { PersonaRadar } from './PersonaRadar'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -39,8 +49,7 @@ function navToSettings(tab: ConfigTab) { interface ToolInfo { name: string; description: string; is_readonly: boolean; } const C = 'bp'; -const IDENTITY_KEY = 'bf_agent_identity'; -const DEFAULT_NAME = 'BitFun Agent'; +const DEFAULT_AGENT_NAME = 'BitFun Agent'; const CHIP_LIMIT = 12; const TOOL_LIST_LIMIT = 10; const SKILL_GRID_LIMIT = 4; @@ -294,22 +303,20 @@ const ModelPill: React.FC = ({ }; const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => { const { t } = useTranslation('scenes/profile'); - - // Initialize identity from localStorage immediately (lazy initializer avoids flash) - const [identity, setIdentity] = useState<{ name: string; desc: string }>(() => { - try { - const s = localStorage.getItem(IDENTITY_KEY); - if (s) return JSON.parse(s) as { name: string; desc: string }; - } catch { /* ignore */ } - return { name: DEFAULT_NAME, desc: '' }; - }); - const [editingField, setEditingField] = useState<'name' | 'desc' | null>(null); + const { + document: identityDocument, + updateField: updateIdentityField, + resetPersonaFiles, + } = useAgentIdentityDocument(workspacePath); + const [editingField, setEditingField] = useState< + 'name' | 'body' | 'emoji' | 'creature' | 'vibe' | null + >(null); const [editValue, setEditValue] = useState(''); const nameInputRef = useRef(null); - const descInputRef = useRef(null); + const metaInputRef = useRef(null); + const bodyTextareaRef = useRef(null); const [models, setModels] = useState([]); - const [, setDefaultModels] = useState(null); const [funcAgentModels, setFuncAgentModels] = useState>({}); const [rules, setRules] = useState([]); const [memories, setMemories] = useState([]); @@ -345,6 +352,7 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => // home ↔ detail view transition const [detailMode, setDetailMode] = useState(false); + const [isResetIdentityDialogOpen, setIsResetIdentityDialogOpen] = useState(false); // section refs for radar-click scroll navigation const rulesRef = useRef(null); @@ -394,7 +402,7 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => const loadCaps = useCallback(async () => { try { const { invoke } = await import('@tauri-apps/api/core'); - const [tools, mcps, sks, modeConf, allModels, defModels, funcModels, exp] = await Promise.all([ + const [tools, mcps, sks, modeConf, allModels, funcModels, exp] = await Promise.all([ invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), configAPI.getSkillConfigs({ @@ -402,7 +410,6 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => }).catch(() => [] as SkillInfo[]), configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), (configManager.getConfig('ai.models') as Promise).catch(() => [] as AIModelConfig[]), - (configManager.getConfig('ai.default_models') as Promise).catch(() => null), (configManager.getConfig>('ai.func_agent_models') as Promise>).catch(() => ({} as Record)), configAPI.getConfig('app.ai_experience').catch(() => null) as Promise, ]); @@ -411,31 +418,94 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => setSkills(sks); setAgenticConfig(modeConf); setModels(allModels ?? []); - setDefaultModels(defModels); setFuncAgentModels(funcModels ?? {}); if (exp) setAiExp(exp); } catch (e) { log.error('capabilities', e); } }, [workspacePath]); useEffect(() => { loadCaps(); }, [loadCaps]); - const startEdit = (field: 'name' | 'desc') => { + const identityName = identityDocument.name || DEFAULT_AGENT_NAME; + const identityBodyFallback = t('defaultDesc', { + defaultValue: '用 IDENTITY.md 的正文补充你的设定、风格、边界和偏好。', + }); + const identityMetaItems = useMemo( + () => [ + { + key: 'emoji' as const, + label: t('identity.emoji'), + value: identityDocument.emoji, + placeholder: t('identity.emojiPlaceholder'), + }, + { + key: 'creature' as const, + label: t('identity.creature'), + value: identityDocument.creature, + placeholder: t('identity.creaturePlaceholderShort'), + }, + { + key: 'vibe' as const, + label: t('identity.vibe'), + value: identityDocument.vibe, + placeholder: t('identity.vibePlaceholderShort'), + }, + ] as const, + [identityDocument.creature, identityDocument.emoji, identityDocument.vibe, t] + ); + + const startEdit = (field: 'name' | 'body' | 'emoji' | 'creature' | 'vibe') => { setEditingField(field); - setEditValue(field === 'name' ? identity.name : (identity.desc || t('defaultDesc'))); - setTimeout(() => (field === 'name' ? nameInputRef : descInputRef).current?.focus(), 10); + const nextValue = + field === 'name' + ? identityDocument.name + : field === 'body' + ? identityDocument.body + : identityDocument[field]; + + setEditValue(nextValue); + setTimeout(() => { + if (field === 'name') { + nameInputRef.current?.focus(); + return; + } + + if (field === 'body') { + bodyTextareaRef.current?.focus(); + return; + } + + metaInputRef.current?.focus(); + }, 10); }; const commitEdit = useCallback(() => { if (!editingField) return; - const fallback = editingField === 'name' ? DEFAULT_NAME : t('defaultDesc'); - const updated = { ...identity, [editingField === 'name' ? 'name' : 'desc']: editValue.trim() || fallback }; - setIdentity(updated); - localStorage.setItem(IDENTITY_KEY, JSON.stringify(updated)); + if (editingField === 'name') { + updateIdentityField('name', editValue.trim()); + } else if (editingField === 'body') { + updateIdentityField('body', editValue.replace(/\r\n/g, '\n')); + } else { + updateIdentityField(editingField, editValue.trim()); + } setEditingField(null); - }, [editingField, editValue, identity, t]); - const onEditKey = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') commitEdit(); + }, [editValue, editingField, updateIdentityField]); + const onEditKey = (e: React.KeyboardEvent) => { + if (editingField !== 'body' && e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingField(null); }; + const handleConfirmResetIdentity = useCallback(async () => { + setEditingField(null); + setEditValue(''); + + try { + await resetPersonaFiles(); + notificationService.success(t('identity.resetSuccess')); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('identity.resetFailed') + ); + } + }, [resetPersonaFiles, t]); + const openRadar = useCallback(() => setRadarOpen(true), []); const closeRadar = useCallback(() => { setRadarClosing(true); @@ -930,46 +1000,97 @@ const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => inputSize="small" /> ) : ( -

startEdit('name')} - title={t('hero.editNameTitle')} - > - {identity.name} - -

+ <> +

startEdit('name')} + title={t('hero.editNameTitle')} + > + {identityName} + +

+ + + + )} -
- - WIP · 建设中 -
+
+ +
+ {identityMetaItems.map((item) => { + const isEditingMeta = editingField === item.key; + const displayValue = item.value || item.placeholder; + return ( + !isEditingMeta && startEdit(item.key)} + > + {isEditingMeta ? ( + setEditValue(event.target.value)} + onBlur={commitEdit} + onKeyDown={onEditKey} + placeholder={item.placeholder} + inputSize="small" + /> + ) : ( + <> + {item.key !== 'emoji' && {item.label}} + {displayValue} + + )} + + ); + })}
{/* Description + Radar side by side */}
-
!editingField && startEdit('desc')}> - {editingField === 'desc' ? ( - !editingField && startEdit('body')}> + {editingField === 'body' ? ( +