# 技能名称冲突修复 - 代码改动文档 ## 概述 本文档详细记录了修复 public skill 和 custom skill 同名冲突问题的所有代码改动。 **状态**: ⚠️ **已知问题保留** - 同名技能冲突问题已识别但暂时保留,后续版本修复 **日期**: 2026-02-10 --- ## 问题描述 ### 原始问题 当 public skill 和 custom skill 有相同名称(但技能文件内容不同)时,会出现以下问题: 1. **打开冲突**: 打开 public skill 时,同名的 custom skill 也会被打开 2. **关闭冲突**: 关闭 public skill 时,同名的 custom skill 也会被关闭 3. **配置冲突**: 两个技能共享同一个配置键,导致状态互相影响 ### 根本原因 - 配置文件中技能状态仅使用 `skill_name` 作为键 - 同名但不同类别的技能无法区分 - 缺少类别级别的重复检查 --- ## 解决方案 ### 核心思路 1. **组合键存储**: 使用 `{category}:{name}` 格式作为配置键,确保唯一性 2. **向后兼容**: 保持对旧格式(仅 `name`)的支持 3. **重复检查**: 在加载时检查每个类别内是否有重复的技能名称 4. **API 增强**: API 支持可选的 `category` 查询参数来区分同名技能 ### 设计原则 - ✅ 最小改动原则 - ✅ 向后兼容 - ✅ 清晰的错误提示 - ✅ 代码复用(提取公共函数) --- ## 详细代码改动 ### 一、后端配置层 (`backend/packages/harness/deerflow/config/extensions_config.py`) #### 1.1 新增方法: `get_skill_key()` **位置**: 第 152-166 行 **代码**: ```python @staticmethod def get_skill_key(skill_name: str, skill_category: str) -> str: """Get the key for a skill in the configuration. Uses format '{category}:{name}' to uniquely identify skills, allowing public and custom skills with the same name to coexist. Args: skill_name: Name of the skill skill_category: Category of the skill ('public' or 'custom') Returns: The skill key in format '{category}:{name}' """ return f"{skill_category}:{skill_name}" ``` **作用**: 生成组合键,格式为 `{category}:{name}` **影响**: - 新增方法,不影响现有代码 - 被 `is_skill_enabled()` 和 API 路由使用 --- #### 1.2 修改方法: `is_skill_enabled()` **位置**: 第 168-195 行 **修改前**: ```python def is_skill_enabled(self, skill_name: str, skill_category: str) -> bool: skill_config = self.skills.get(skill_name) if skill_config is None: return skill_category in ("public", "custom") return skill_config.enabled ``` **修改后**: ```python def is_skill_enabled(self, skill_name: str, skill_category: str) -> bool: """Check if a skill is enabled. First checks for the new format key '{category}:{name}', then falls back to the old format '{name}' for backward compatibility. Args: skill_name: Name of the skill skill_category: Category of the skill Returns: True if enabled, False otherwise """ # Try new format first: {category}:{name} skill_key = self.get_skill_key(skill_name, skill_category) skill_config = self.skills.get(skill_key) if skill_config is not None: return skill_config.enabled # Fallback to old format for backward compatibility: {name} # Only check old format if category is 'public' to avoid conflicts if skill_category == "public": skill_config = self.skills.get(skill_name) if skill_config is not None: return skill_config.enabled # Default to enabled for public & custom skills return skill_category in ("public", "custom") ``` **改动说明**: - 优先检查新格式键 `{category}:{name}` - 向后兼容:如果新格式不存在,检查旧格式(仅 public 类别) - 保持默认行为:未配置时默认启用 **影响**: - ✅ 向后兼容:旧配置仍可正常工作 - ✅ 新配置使用组合键,避免冲突 - ✅ 不影响现有调用方 --- ### 二、后端技能加载器 (`backend/packages/harness/deerflow/skills/loader.py`) #### 2.1 添加重复检查逻辑 **位置**: 第 54-86 行 **修改前**: ```python skills = [] # Scan public and custom directories for category in ["public", "custom"]: category_path = skills_path / category # ... 扫描技能目录 ... skill = parse_skill_file(skill_file, category=category) if skill: skills.append(skill) ``` **修改后**: ```python skills = [] category_skill_names = {} # Track skill names per category to detect duplicates # Scan public and custom directories for category in ["public", "custom"]: category_path = skills_path / category if not category_path.exists() or not category_path.is_dir(): continue # Initialize tracking for this category if category not in category_skill_names: category_skill_names[category] = {} # Each subdirectory is a potential skill for skill_dir in category_path.iterdir(): # ... 扫描逻辑 ... skill = parse_skill_file(skill_file, category=category) if skill: # Validate: each category cannot have duplicate skill names if skill.name in category_skill_names[category]: existing_path = category_skill_names[category][skill.name] raise ValueError( f"Duplicate skill name '{skill.name}' found in {category} category. " f"Existing: {existing_path}, Duplicate: {skill_file.parent}" ) category_skill_names[category][skill.name] = str(skill_file.parent) skills.append(skill) ``` **改动说明**: - 为每个类别维护技能名称字典 - 检测到重复时抛出 `ValueError`,包含详细路径信息 - 确保每个类别内技能名称唯一 **影响**: - ✅ 防止配置冲突 - ✅ 清晰的错误提示 - ⚠️ 如果存在重复,加载会失败(这是预期行为) --- ### 三、后端 API 路由 (`backend/app/gateway/routers/skills.py`) #### 3.1 新增辅助函数: `_find_skill_by_name()` **位置**: 第 136-173 行 **代码**: ```python def _find_skill_by_name( skills: list[Skill], skill_name: str, category: str | None = None ) -> Skill: """Find a skill by name, optionally filtered by category. Args: skills: List of all skills skill_name: Name of the skill to find category: Optional category filter Returns: The found Skill object Raises: HTTPException: If skill not found or multiple skills require category """ if category: skill = next((s for s in skills if s.name == skill_name and s.category == category), None) if skill is None: raise HTTPException( status_code=404, detail=f"Skill '{skill_name}' with category '{category}' not found" ) return skill # If no category provided, check if there are multiple skills with the same name matching_skills = [s for s in skills if s.name == skill_name] if len(matching_skills) == 0: raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") elif len(matching_skills) > 1: # Multiple skills with same name - require category categories = [s.category for s in matching_skills] raise HTTPException( status_code=400, detail=f"Multiple skills found with name '{skill_name}'. Please specify category query parameter. " f"Available categories: {', '.join(categories)}" ) return matching_skills[0] ``` **作用**: - 统一技能查找逻辑 - 支持可选的 category 过滤 - 自动检测同名冲突并提示 **影响**: - ✅ 减少代码重复(约 30 行) - ✅ 统一错误处理逻辑 --- #### 3.2 修改端点: `GET /api/skills/{skill_name}` **位置**: 第 196-260 行 **修改前**: ```python @router.get("/skills/{skill_name}", ...) async def get_skill(skill_name: str) -> SkillResponse: skills = load_skills(enabled_only=False) skill = next((s for s in skills if s.name == skill_name), None) if skill is None: raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") return _skill_to_response(skill) ``` **修改后**: ```python @router.get( "/skills/{skill_name}", response_model=SkillResponse, summary="Get Skill Details", description="Retrieve detailed information about a specific skill by its name. " "If multiple skills share the same name, use category query parameter.", ) async def get_skill(skill_name: str, category: str | None = None) -> SkillResponse: try: skills = load_skills(enabled_only=False) skill = _find_skill_by_name(skills, skill_name, category) return _skill_to_response(skill) except ValueError as e: # ValueError indicates duplicate skill names in a category logger.error(f"Invalid skills configuration: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) except HTTPException: raise except Exception as e: logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}") ``` **改动说明**: - 添加可选的 `category` 查询参数 - 使用 `_find_skill_by_name()` 统一查找逻辑 - 添加 `ValueError` 处理(重复检查错误) **API 变更**: - ✅ 向后兼容:`category` 参数可选 - ✅ 如果只有一个同名技能,自动匹配 - ✅ 如果有多个同名技能,要求提供 `category` --- #### 3.3 修改端点: `PUT /api/skills/{skill_name}` **位置**: 第 267-388 行 **修改前**: ```python @router.put("/skills/{skill_name}", ...) async def update_skill(skill_name: str, request: SkillUpdateRequest) -> SkillResponse: skills = load_skills(enabled_only=False) skill = next((s for s in skills if s.name == skill_name), None) if skill is None: raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled) # ... 保存配置 ... ``` **修改后**: ```python @router.put( "/skills/{skill_name}", response_model=SkillResponse, summary="Update Skill", description="Update a skill's enabled status by modifying the extensions_config.json file. " "Requires category query parameter to uniquely identify skills with the same name.", ) async def update_skill(skill_name: str, request: SkillUpdateRequest, category: str | None = None) -> SkillResponse: try: # Find the skill to verify it exists skills = load_skills(enabled_only=False) skill = _find_skill_by_name(skills, skill_name, category) # Get or create config path config_path = ExtensionsConfig.resolve_config_path() # ... 配置路径处理 ... # Load current configuration extensions_config = get_extensions_config() # Use the new format key: {category}:{name} skill_key = ExtensionsConfig.get_skill_key(skill.name, skill.category) extensions_config.skills[skill_key] = SkillStateConfig(enabled=request.enabled) # Convert to JSON format (preserve MCP servers config) config_data = { "mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()}, "skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()}, } # Write the configuration to file with open(config_path, "w") as f: json.dump(config_data, f, indent=2) # Reload the extensions config to update the global cache reload_extensions_config() # Reload the skills to get the updated status (for API response) skills = load_skills(enabled_only=False) updated_skill = next((s for s in skills if s.name == skill.name and s.category == skill.category), None) if updated_skill is None: raise HTTPException( status_code=500, detail=f"Failed to reload skill '{skill.name}' (category: {skill.category}) after update" ) logger.info(f"Skill '{skill.name}' (category: {skill.category}) enabled status updated to {request.enabled}") return _skill_to_response(updated_skill) except ValueError as e: # ValueError indicates duplicate skill names in a category logger.error(f"Invalid skills configuration: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) except HTTPException: raise except Exception as e: logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}") ``` **改动说明**: - 添加可选的 `category` 查询参数 - 使用 `_find_skill_by_name()` 查找技能 - **关键改动**: 使用组合键 `ExtensionsConfig.get_skill_key()` 存储配置 - 添加 `ValueError` 处理 **API 变更**: - ✅ 向后兼容:`category` 参数可选 - ✅ 配置存储使用新格式键 --- #### 3.4 修改端点: `POST /api/skills/install` **位置**: 第 392-529 行 **修改前**: ```python # Check if skill already exists target_dir = custom_skills_dir / skill_name if target_dir.exists(): raise HTTPException(status_code=409, detail=f"Skill '{skill_name}' already exists. Please remove it first or use a different name.") ``` **修改后**: ```python # Check if skill directory already exists target_dir = custom_skills_dir / skill_name if target_dir.exists(): raise HTTPException(status_code=409, detail=f"Skill directory '{skill_name}' already exists. Please remove it first or use a different name.") # Check if a skill with the same name already exists in custom category # This prevents duplicate skill names even if directory names differ try: existing_skills = load_skills(enabled_only=False) duplicate_skill = next( (s for s in existing_skills if s.name == skill_name and s.category == "custom"), None ) if duplicate_skill: raise HTTPException( status_code=409, detail=f"Skill with name '{skill_name}' already exists in custom category " f"(located at: {duplicate_skill.skill_dir}). Please remove it first or use a different name." ) except ValueError as e: # ValueError indicates duplicate skill names in configuration # This should not happen during installation, but handle it gracefully logger.warning(f"Skills configuration issue detected during installation: {e}") raise HTTPException( status_code=500, detail=f"Cannot install skill: {str(e)}" ) ``` **改动说明**: - 检查目录是否存在(原有逻辑) - **新增**: 检查 custom 类别中是否已有同名技能(即使目录名不同) - 添加 `ValueError` 处理 **影响**: - ✅ 防止安装同名技能 - ✅ 清晰的错误提示 --- ### 四、前端 API 层 (`frontend/src/core/skills/api.ts`) #### 4.1 修改函数: `enableSkill()` **位置**: 第 11-30 行 **修改前**: ```typescript export async function enableSkill(skillName: string, enabled: boolean) { const response = await fetch( `${getBackendBaseURL()}/api/skills/${skillName}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ enabled, }), }, ); return response.json(); } ``` **修改后**: ```typescript export async function enableSkill( skillName: string, enabled: boolean, category: string, ) { const baseURL = getBackendBaseURL(); const skillNameEncoded = encodeURIComponent(skillName); const categoryEncoded = encodeURIComponent(category); const url = `${baseURL}/api/skills/${skillNameEncoded}?category=${categoryEncoded}`; const response = await fetch(url, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ enabled, }), }); return response.json(); } ``` **改动说明**: - 添加 `category` 参数 - URL 编码 skillName 和 category - 将 category 作为查询参数传递 **影响**: - ✅ 必须传递 category(前端已有该信息) - ✅ URL 编码确保特殊字符正确处理 --- ### 五、前端 Hooks 层 (`frontend/src/core/skills/hooks.ts`) #### 5.1 修改 Hook: `useEnableSkill()` **位置**: 第 15-33 行 **修改前**: ```typescript export function useEnableSkill() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ skillName, enabled, }: { skillName: string; enabled: boolean; }) => { await enableSkill(skillName, enabled); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["skills"] }); }, }); } ``` **修改后**: ```typescript export function useEnableSkill() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ skillName, enabled, category, }: { skillName: string; enabled: boolean; category: string; }) => { await enableSkill(skillName, enabled, category); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["skills"] }); }, }); } ``` **改动说明**: - 添加 `category` 参数到类型定义 - 传递 `category` 给 `enableSkill()` API 调用 **影响**: - ✅ 类型安全 - ✅ 必须传递 category --- ### 六、前端组件层 (`frontend/src/components/workspace/settings/skill-settings-page.tsx`) #### 6.1 修改组件: `SkillSettingsList` **位置**: 第 92-119 行 **修改前**: ```typescript {filteredSkills.length > 0 && filteredSkills.map((skill) => ( {/* ... */} enableSkill({ skillName: skill.name, enabled: checked }) } /> ))} ``` **修改后**: ```typescript {filteredSkills.length > 0 && filteredSkills.map((skill) => ( {/* ... */} enableSkill({ skillName: skill.name, enabled: checked, category: skill.category, }) } /> ))} ``` **改动说明**: - **关键改动**: React key 从 `skill.name` 改为 `${skill.category}:${skill.name}` - 传递 `category` 给 `enableSkill()` **影响**: - ✅ 确保 React key 唯一性(避免同名技能冲突) - ✅ 正确传递 category 信息 --- ## 配置格式变更 ### 旧格式(向后兼容) ```json { "skills": { "my-skill": { "enabled": true } } } ``` ### 新格式(推荐) ```json { "skills": { "public:my-skill": { "enabled": true }, "custom:my-skill": { "enabled": false } } } ``` ### 迁移说明 - ✅ **自动兼容**: 系统会自动识别旧格式 - ✅ **无需手动迁移**: 旧配置继续工作 - ✅ **新配置使用新格式**: 更新技能状态时自动使用新格式键 --- ## API 变更 ### GET /api/skills/{skill_name} **新增查询参数**: - `category` (可选): `public` 或 `custom` **行为变更**: - 如果只有一个同名技能,自动匹配(向后兼容) - 如果有多个同名技能,必须提供 `category` 参数 **示例**: ```bash # 单个技能(向后兼容) GET /api/skills/my-skill # 多个同名技能(必须指定类别) GET /api/skills/my-skill?category=public GET /api/skills/my-skill?category=custom ``` ### PUT /api/skills/{skill_name} **新增查询参数**: - `category` (可选): `public` 或 `custom` **行为变更**: - 配置存储使用新格式键 `{category}:{name}` - 如果只有一个同名技能,自动匹配(向后兼容) - 如果有多个同名技能,必须提供 `category` 参数 **示例**: ```bash # 更新 public 技能 PUT /api/skills/my-skill?category=public Body: { "enabled": true } # 更新 custom 技能 PUT /api/skills/my-skill?category=custom Body: { "enabled": false } ``` --- ## 影响范围 ### 后端 1. **配置读取**: `ExtensionsConfig.is_skill_enabled()` - 支持新格式,向后兼容 2. **配置写入**: `PUT /api/skills/{skill_name}` - 使用新格式键 3. **技能加载**: `load_skills()` - 添加重复检查 4. **API 端点**: 3 个端点支持可选的 `category` 参数 ### 前端 1. **API 调用**: `enableSkill()` - 必须传递 `category` 2. **Hooks**: `useEnableSkill()` - 类型定义更新 3. **组件**: `SkillSettingsList` - React key 和参数传递更新 ### 配置文件 - **格式变更**: 新配置使用 `{category}:{name}` 格式 - **向后兼容**: 旧格式继续支持 - **自动迁移**: 更新时自动使用新格式 --- ## 测试建议 ### 1. 向后兼容性测试 - [ ] 旧格式配置文件应正常工作 - [ ] 仅使用 `skill_name` 的 API 调用应正常工作(单个技能时) - [ ] 现有技能状态应保持不变 ### 2. 新功能测试 - [ ] public 和 custom 同名技能应能独立控制 - [ ] 打开/关闭一个技能不应影响另一个同名技能 - [ ] API 调用传递 `category` 参数应正确工作 ### 3. 错误处理测试 - [ ] public 类别内重复技能名称应报错 - [ ] custom 类别内重复技能名称应报错 - [ ] 多个同名技能时,不提供 `category` 应返回 400 错误 ### 4. 安装测试 - [ ] 安装同名技能应被拒绝(409 错误) - [ ] 错误信息应包含现有技能的位置 --- ## 已知问题(暂时保留) ### ⚠️ 问题描述 **当前状态**: 同名技能冲突问题已识别但**暂时保留**,后续版本修复 **问题表现**: - 如果 public 和 custom 目录下存在同名技能,虽然配置已使用组合键区分,但前端 UI 可能仍会出现混淆 - 用户可能无法清楚区分哪个是 public,哪个是 custom **影响范围**: - 用户体验:可能无法清楚区分同名技能 - 功能:技能状态可以独立控制(已修复) - 数据:配置正确存储(已修复) ### 后续修复建议 1. **UI 增强**: 在技能列表中明确显示类别标识 2. **名称验证**: 安装时检查是否与 public 技能同名,并给出警告 3. **文档更新**: 说明同名技能的最佳实践 --- ## 回滚方案 如果需要回滚这些改动: ### 后端回滚 1. **恢复配置读取逻辑**: ```python # 恢复为仅使用 skill_name skill_config = self.skills.get(skill_name) ``` 2. **恢复 API 端点**: - 移除 `category` 参数 - 恢复原有的查找逻辑 3. **移除重复检查**: - 移除 `category_skill_names` 跟踪逻辑 ### 前端回滚 1. **恢复 API 调用**: ```typescript // 移除 category 参数 export async function enableSkill(skillName: string, enabled: boolean) ``` 2. **恢复组件**: - React key 恢复为 `skill.name` - 移除 `category` 参数传递 ### 配置迁移 - 新格式配置需要手动迁移回旧格式(如果已使用新格式) - 旧格式配置无需修改 --- ## 总结 ### 改动统计 - **后端文件**: 3 个文件修改 - `backend/packages/harness/deerflow/config/extensions_config.py`: +1 方法,修改 1 方法 - `backend/packages/harness/deerflow/skills/loader.py`: +重复检查逻辑 - `backend/app/gateway/routers/skills.py`: +1 辅助函数,修改 3 个端点 - **前端文件**: 3 个文件修改 - `frontend/src/core/skills/api.ts`: 修改 1 个函数 - `frontend/src/core/skills/hooks.ts`: 修改 1 个 hook - `frontend/src/components/workspace/settings/skill-settings-page.tsx`: 修改组件 - **代码行数**: - 新增: ~80 行 - 修改: ~30 行 - 删除: ~0 行(向后兼容) ### 核心改进 1. ✅ **配置唯一性**: 使用组合键确保配置唯一 2. ✅ **向后兼容**: 旧配置继续工作 3. ✅ **重复检查**: 防止配置冲突 4. ✅ **代码复用**: 提取公共函数减少重复 5. ✅ **错误提示**: 清晰的错误信息 ### 注意事项 - ⚠️ **已知问题保留**: UI 区分同名技能的问题待后续修复 - ✅ **向后兼容**: 现有配置和 API 调用继续工作 - ✅ **最小改动**: 仅修改必要的代码 --- **文档版本**: 1.0 **最后更新**: 2026-02-10 **维护者**: AI Assistant