diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 06a8b8b3..f9e2d9e7 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -2,8 +2,8 @@ use crate::api::app_state::AppState; use bitfun_core::agentic::agents::{ - AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentKind, - SubAgentSource, + AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentDetail, + CustomSubagentKind, SubAgentSource, }; use bitfun_core::service::config::types::SubAgentConfig; use log::warn; @@ -48,6 +48,26 @@ pub async fn list_subagents( Ok(result) } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSubagentDetailRequest { + pub subagent_id: String, + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn get_subagent_detail( + state: State<'_, AppState>, + request: GetSubagentDetailRequest, +) -> Result { + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + state + .agent_registry + .get_custom_subagent_detail(&request.subagent_id, workspace.as_deref()) + .await + .map_err(|e| e.to_string()) +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeleteSubagentRequest { @@ -113,6 +133,43 @@ pub async fn delete_subagent( Ok(()) } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSubagentRequest { + pub subagent_id: String, + pub description: String, + pub prompt: String, + pub tools: Option>, + pub readonly: Option, + pub workspace_path: Option, +} + +#[tauri::command] +pub async fn update_subagent( + state: State<'_, AppState>, + request: UpdateSubagentRequest, +) -> Result<(), String> { + if request.description.trim().is_empty() { + return Err("Description cannot be empty".to_string()); + } + if request.prompt.trim().is_empty() { + return Err("Prompt cannot be empty".to_string()); + } + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + state + .agent_registry + .update_custom_subagent_definition( + &request.subagent_id, + workspace.as_deref(), + request.description.trim().to_string(), + request.prompt.trim().to_string(), + request.tools, + request.readonly, + ) + .await + .map_err(|e| e.to_string()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SubagentLevel { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 729955d5..19509401 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -404,8 +404,10 @@ pub async fn run() { get_subagent_configs, set_subagent_config, list_subagents, + get_subagent_detail, delete_subagent, create_subagent, + update_subagent, reload_subagents, list_agent_tool_names, update_subagent_config, diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 37e7ebb0..faa52024 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -35,7 +35,7 @@ pub use plan_mode::PlanMode; pub use prompt_builder::{PromptBuilder, PromptBuilderContext, RemoteExecutionHints}; pub use registry::{ get_agent_registry, AgentCategory, AgentInfo, AgentRegistry, CustomSubagentConfig, - SubAgentSource, + CustomSubagentDetail, SubAgentSource, }; use std::any::Any; diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index 78a9ef5e..536de37a 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -108,6 +108,23 @@ impl AgentInfo { } } +/// Full sub-agent definition for editing (user/project custom agents only) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomSubagentDetail { + pub subagent_id: String, + pub name: String, + pub description: String, + pub prompt: String, + pub tools: Vec, + pub readonly: bool, + pub enabled: bool, + pub model: String, + pub path: String, + /// `"user"` or `"project"` + pub level: String, +} + fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { match agent_type { "agentic" | "Cowork" | "Plan" | "debug" | "Claw" => "auto", @@ -674,6 +691,215 @@ impl AgentRegistry { Ok(()) } + /// Load custom subagents if needed, then return full definition for the editor UI + pub async fn get_custom_subagent_detail( + &self, + agent_id: &str, + workspace_root: Option<&Path>, + ) -> BitFunResult { + if let Some(root) = workspace_root { + self.load_custom_subagents(root).await; + } + self.get_custom_subagent_detail_inner(agent_id, workspace_root) + } + + fn get_custom_subagent_detail_inner( + &self, + agent_id: &str, + workspace_root: Option<&Path>, + ) -> BitFunResult { + let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| { + BitFunError::agent(format!("Subagent not found: {}", agent_id)) + })?; + if entry.category != AgentCategory::SubAgent { + return Err(BitFunError::agent(format!( + "Agent '{}' is not a subagent", + agent_id + ))); + } + if entry.subagent_source == Some(SubAgentSource::Builtin) { + return Err(BitFunError::agent( + "Built-in subagents cannot be edited here".to_string(), + )); + } + let custom = entry + .agent + .as_any() + .downcast_ref::() + .ok_or_else(|| { + BitFunError::agent(format!( + "Subagent '{}' is not a custom subagent file", + agent_id + )) + })?; + let (enabled, model) = match &entry.custom_config { + Some(c) => (c.enabled, c.model.clone()), + None => (custom.enabled, custom.model.clone()), + }; + let level = match custom.kind { + CustomSubagentKind::User => "user", + CustomSubagentKind::Project => "project", + }; + Ok(CustomSubagentDetail { + subagent_id: agent_id.to_string(), + name: custom.name.clone(), + description: custom.description.clone(), + prompt: custom.prompt.clone(), + tools: custom.tools.clone(), + readonly: custom.readonly, + enabled, + model, + path: custom.path.clone(), + level: level.to_string(), + }) + } + + /// Update description, prompt, tools, and readonly for a custom sub-agent (id and file path unchanged) + pub async fn update_custom_subagent_definition( + &self, + agent_id: &str, + workspace_root: Option<&Path>, + description: String, + prompt: String, + tools: Option>, + readonly: Option, + ) -> BitFunResult<()> { + if let Some(root) = workspace_root { + self.load_custom_subagents(root).await; + } + let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| { + BitFunError::agent(format!("Subagent not found: {}", agent_id)) + })?; + if entry.category != AgentCategory::SubAgent { + return Err(BitFunError::agent(format!( + "Agent '{}' is not a subagent", + agent_id + ))); + } + if entry.subagent_source == Some(SubAgentSource::Builtin) { + return Err(BitFunError::agent( + "Built-in subagents cannot be edited".to_string(), + )); + } + let old = entry + .agent + .as_any() + .downcast_ref::() + .ok_or_else(|| { + BitFunError::agent(format!( + "Subagent '{}' is not a custom subagent file", + agent_id + )) + })?; + let tools = tools + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| { + vec![ + "LS".to_string(), + "Read".to_string(), + "Glob".to_string(), + "Grep".to_string(), + ] + }); + let mut new_subagent = CustomSubagent::new( + old.name.clone(), + description, + tools, + prompt, + readonly.unwrap_or(old.readonly), + old.path.clone(), + old.kind, + ); + new_subagent.enabled = old.enabled; + new_subagent.model = old.model.clone(); + + let valid_tools = get_all_registered_tool_names().await; + let valid_models = Self::get_valid_model_ids().await; + Self::validate_custom_subagent(&mut new_subagent, &valid_tools, &valid_models); + + new_subagent.save_to_file(None, None)?; + + self.replace_custom_subagent_entry(agent_id, workspace_root, new_subagent) + } + + fn replace_custom_subagent_entry( + &self, + agent_id: &str, + workspace_root: Option<&Path>, + new_subagent: CustomSubagent, + ) -> BitFunResult<()> { + let mut map = self.write_agents(); + if map.contains_key(agent_id) { + let old_entry = map.get(agent_id).ok_or_else(|| { + BitFunError::agent(format!("Subagent not found: {}", agent_id)) + })?; + if old_entry.category != AgentCategory::SubAgent { + return Err(BitFunError::agent(format!( + "Agent '{}' is not a subagent", + agent_id + ))); + } + if old_entry.subagent_source == Some(SubAgentSource::Builtin) { + return Err(BitFunError::agent( + "Cannot replace built-in subagent".to_string(), + )); + } + let subagent_source = old_entry.subagent_source; + let cfg = CustomSubagentConfig { + enabled: new_subagent.enabled, + model: new_subagent.model.clone(), + }; + map.insert( + agent_id.to_string(), + AgentEntry { + category: AgentCategory::SubAgent, + subagent_source, + agent: Arc::new(new_subagent), + custom_config: Some(cfg), + }, + ); + return Ok(()); + } + drop(map); + + let root = workspace_root.ok_or_else(|| { + BitFunError::agent("Workspace path is required to update project subagent".to_string()) + })?; + let mut pm = self.write_project_subagents(); + let entries = pm.get_mut(root).ok_or_else(|| { + BitFunError::agent("Project subagent cache not loaded for this workspace".to_string()) + })?; + let old_entry = entries.get(agent_id).ok_or_else(|| { + BitFunError::agent(format!("Subagent not found: {}", agent_id)) + })?; + if old_entry.category != AgentCategory::SubAgent { + return Err(BitFunError::agent(format!( + "Agent '{}' is not a subagent", + agent_id + ))); + } + if old_entry.subagent_source == Some(SubAgentSource::Builtin) { + return Err(BitFunError::agent( + "Cannot replace built-in subagent".to_string(), + )); + } + let subagent_source = old_entry.subagent_source; + let cfg = CustomSubagentConfig { + enabled: new_subagent.enabled, + model: new_subagent.model.clone(), + }; + entries.insert( + agent_id.to_string(), + AgentEntry { + category: AgentCategory::SubAgent, + subagent_source, + agent: Arc::new(new_subagent), + custom_config: Some(cfg), + }, + ); + Ok(()) + } + /// remove single non-built-in subagent, return its file path (used for caller to delete file) /// only allow removing entries that are SubAgent and not Builtin pub fn remove_subagent(&self, agent_id: &str) -> BitFunResult> { diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index be403a2b..007463fb 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -1,15 +1,17 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Bot, Cpu, + Pencil, Plus, Puzzle, RefreshCw, Search as SearchIcon, + Trash2, Wrench, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Badge, Button, IconButton, Search } from '@/component-library'; +import { Badge, Button, IconButton, Search, confirmDanger } from '@/component-library'; import { GalleryDetailModal, GalleryEmpty, @@ -34,9 +36,13 @@ import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; +import { SubagentAPI } from '@/infrastructure/api/service-api/SubagentAPI'; +import { useNotification } from '@/shared/notification-system'; const AgentsHomeView: React.FC = () => { const { t } = useTranslation('scenes/agents'); + const notification = useNotification(); + const [deletingAgent, setDeletingAgent] = useState(false); const { agentSoloEnabled, searchQuery, @@ -47,6 +53,7 @@ const AgentsHomeView: React.FC = () => { setAgentFilterType, setAgentSoloEnabled, openCreateAgent, + openEditAgent, } = useAgentsStore(); const [selectedAgentId, setSelectedAgentId] = React.useState(null); const [toolsEditing, setToolsEditing] = React.useState(false); @@ -151,6 +158,42 @@ const AgentsHomeView: React.FC = () => { resetEditState(); }, [resetEditState]); + const handleDeleteCustomAgent = useCallback(async () => { + if (!selectedAgent) return; + if ( + selectedAgent.agentKind !== 'subagent' + || (selectedAgent.subagentSource !== 'user' && selectedAgent.subagentSource !== 'project') + ) { + return; + } + const id = selectedAgent.id; + const name = selectedAgent.name; + const ok = await confirmDanger( + t('agentsOverview.deleteAgent'), + t('agentsOverview.deleteConfirm', { name }), + ); + if (!ok) return; + setDeletingAgent(true); + try { + await SubagentAPI.deleteSubagent(id); + notification.success(t('agentsOverview.deleteSuccess', { name })); + closeAgentDetails(); + await loadAgents(); + } catch (e) { + notification.error( + `${t('agentsOverview.deleteFailed')}${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + setDeletingAgent(false); + } + }, [selectedAgent, closeAgentDetails, loadAgents, notification, t]); + + const canManageCustomSubagent = Boolean( + selectedAgent + && selectedAgent.agentKind === 'subagent' + && (selectedAgent.subagentSource === 'user' || selectedAgent.subagentSource === 'project'), + ); + return ( { )} ) : null} + + {canManageCustomSubagent ? ( +
+
+
+ {t('agentsOverview.customActions')} +
+
+
+ + +
+
+ ) : null} ) : null} diff --git a/src/web-ui/src/app/scenes/agents/agentsStore.ts b/src/web-ui/src/app/scenes/agents/agentsStore.ts index bf77e605..ec4ea8bd 100644 --- a/src/web-ui/src/app/scenes/agents/agentsStore.ts +++ b/src/web-ui/src/app/scenes/agents/agentsStore.ts @@ -32,11 +32,14 @@ export const CAPABILITY_COLORS: Record = { }; export type AgentsScenePage = 'home' | 'createAgent'; +export type AgentEditorMode = 'create' | 'edit'; export type AgentFilterLevel = 'all' | 'builtin' | 'user' | 'project'; export type AgentFilterType = 'all' | 'mode' | 'subagent'; interface AgentsStoreState { page: AgentsScenePage; + agentEditorMode: AgentEditorMode; + editingAgentId: string | null; searchQuery: string; agentFilterLevel: AgentFilterLevel; agentFilterType: AgentFilterType; @@ -46,12 +49,15 @@ interface AgentsStoreState { setAgentFilterType: (filter: AgentFilterType) => void; openHome: () => void; openCreateAgent: () => void; + openEditAgent: (agentId: string) => void; agentSoloEnabled: Record; setAgentSoloEnabled: (agentId: string, enabled: boolean) => void; } export const useAgentsStore = create((set) => ({ page: 'home', + agentEditorMode: 'create', + editingAgentId: null, searchQuery: '', agentFilterLevel: 'all', agentFilterType: 'all', @@ -59,8 +65,17 @@ export const useAgentsStore = create((set) => ({ setSearchQuery: (query) => set({ searchQuery: query }), setAgentFilterLevel: (filter) => set({ agentFilterLevel: filter }), setAgentFilterType: (filter) => set({ agentFilterType: filter }), - openHome: () => set({ page: 'home' }), - openCreateAgent: () => set({ page: 'createAgent' }), + openHome: () => set({ page: 'home', agentEditorMode: 'create', editingAgentId: null }), + openCreateAgent: () => set({ + page: 'createAgent', + agentEditorMode: 'create', + editingAgentId: null, + }), + openEditAgent: (agentId: string) => set({ + page: 'createAgent', + agentEditorMode: 'edit', + editingAgentId: agentId, + }), agentSoloEnabled: {}, setAgentSoloEnabled: (agentId, enabled) => set((s) => ({ diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index 9d3dc320..432cd2dd 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -14,10 +14,12 @@ const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const CreateAgentPage: React.FC = () => { const { t } = useTranslation('scenes/agents'); - const { openHome } = useAgentsStore(); + const { openHome, agentEditorMode, editingAgentId } = useAgentsStore(); const notification = useNotification(); const { hasWorkspace, workspacePath } = useCurrentWorkspace(); + const isEdit = agentEditorMode === 'edit' && Boolean(editingAgentId); + const [level, setLevel] = useState('user'); const [name, setName] = useState(''); const [nameError, setNameError] = useState(null); @@ -27,6 +29,8 @@ const CreateAgentPage: React.FC = () => { const [toolNames, setToolNames] = useState([]); const [selectedTools, setSelectedTools] = useState>(new Set()); const [submitting, setSubmitting] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); useEffect(() => { SubagentAPI.listAgentToolNames().then(setToolNames).catch(() => setToolNames([])); @@ -38,6 +42,44 @@ const CreateAgentPage: React.FC = () => { } }, [hasWorkspace, level]); + useEffect(() => { + if (!isEdit || !editingAgentId) { + setDetailLoading(false); + setDetailError(null); + return; + } + + let cancelled = false; + setDetailLoading(true); + setDetailError(null); + + (async () => { + try { + const d = await SubagentAPI.getSubagentDetail({ + subagentId: editingAgentId, + workspacePath: workspacePath || undefined, + }); + if (cancelled) return; + setName(d.name); + setDescription(d.description); + setPrompt(d.prompt); + setReadonly(d.readonly); + setLevel(d.level); + setSelectedTools(new Set(d.tools ?? [])); + setNameError(null); + } catch (e) { + if (cancelled) return; + setDetailError(e instanceof Error ? e.message : String(e)); + } finally { + if (!cancelled) setDetailLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [isEdit, editingAgentId, workspacePath]); + const validateName = useCallback((v: string) => { if (!v.trim()) return t('agentsOverview.form.nameRequired', '名称不能为空'); if (!NAME_REGEX.test(v.trim())) return t('agentsOverview.form.nameFormat', '只能以字母开头,包含字母/数字/下划线/连字符'); @@ -57,30 +99,48 @@ const CreateAgentPage: React.FC = () => { }; const handleSubmit = async () => { - const err = validateName(name); - if (err) { setNameError(err); return; } + if (!isEdit) { + const err = validateName(name); + if (err) { setNameError(err); return; } + } if (!description.trim()) { notification.error(t('agentsOverview.form.descRequired', '描述不能为空')); return; } if (!prompt.trim()) { notification.error(t('agentsOverview.form.promptRequired', '系统提示词不能为空')); return; } if (level === 'project' && !workspacePath) { notification.error(t('agentsOverview.form.noWorkspace', '需要先打开项目')); return; } + if (isEdit && !editingAgentId) { + return; + } + setSubmitting(true); try { - await SubagentAPI.createSubagent({ - level, - name: name.trim(), - description: description.trim(), - prompt: prompt.trim(), - readonly, - tools: selectedTools.size > 0 ? Array.from(selectedTools) : undefined, - workspacePath: level === 'project' ? workspacePath : undefined, - }); - notification.success(t('agentsOverview.form.createSuccess', { name: name.trim() })); + if (isEdit && editingAgentId) { + await SubagentAPI.updateSubagent({ + subagentId: editingAgentId, + description: description.trim(), + prompt: prompt.trim(), + readonly, + tools: selectedTools.size > 0 ? Array.from(selectedTools) : undefined, + workspacePath: level === 'project' ? workspacePath : undefined, + }); + notification.success(t('agentsOverview.form.updateSuccess', { name: name.trim() })); + } else { + await SubagentAPI.createSubagent({ + level, + name: name.trim(), + description: description.trim(), + prompt: prompt.trim(), + readonly, + tools: selectedTools.size > 0 ? Array.from(selectedTools) : undefined, + workspacePath: level === 'project' ? workspacePath : undefined, + }); + notification.success(t('agentsOverview.form.createSuccess', { name: name.trim() })); + } openHome(); } catch (err) { notification.error( - t('agentsOverview.form.createFailed', '创建失败:') + + (isEdit ? t('agentsOverview.form.updateFailed', '保存失败:') : t('agentsOverview.form.createFailed', '创建失败:')) + (err instanceof Error ? err.message : String(err)) ); } finally { @@ -88,26 +148,70 @@ const CreateAgentPage: React.FC = () => { } }; + const formTitle = isEdit + ? t('agentsOverview.form.titleEdit', '编辑 Sub-Agent') + : t('agentsOverview.form.title', '新建 Sub-Agent'); + const formSubtitle = isEdit + ? t('agentsOverview.form.subtitleEdit', '修改描述、提示词、工具与只读设置。名称与级别不可更改。') + : t('agentsOverview.form.subtitle', '创建一个自定义的用户级或项目级 Sub-Agent'); + const submitLabel = isEdit + ? t('agentsOverview.form.save', '保存') + : t('agentsOverview.form.submit', '创建'); + + if (isEdit && detailLoading) { + return ( +
+
+ +
+
+
+

{t('agentsOverview.form.loadingDetail', '加载中…')}

+
+
+
+ ); + } + + if (isEdit && detailError) { + return ( +
+
+ +
+
+
+

{detailError}

+ +
+
+
+ ); + } + return (
- {/* Top bar */}
-
- {/* Page body */}
-

{t('agentsOverview.form.title', '新建 Sub-Agent')}

-

{t('agentsOverview.form.subtitle', '创建一个自定义的用户级或项目级 Sub-Agent')}

+

{formTitle}

+

{formSubtitle}

- {/* Name */}
{ placeholder={t('agentsOverview.form.namePlaceholder', '字母开头,可含字母/数字/下划线')} inputSize="small" error={!!nameError} + disabled={isEdit} /> {nameError && {nameError}}
- {/* Description */}
{ />
- {/* Level + read-only on one row */}
{(['user', 'project'] as SubagentLevel[]).map((lv) => { - const disabled = lv === 'project' && !hasWorkspace; + const disabled = (lv === 'project' && !hasWorkspace) || isEdit; return ( @@ -157,7 +260,6 @@ const CreateAgentPage: React.FC = () => {
- {/* Tools */} {toolNames.length > 0 && (
)} - {/* System prompt */}