mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +08:00
865
docs/SKILL_NAME_CONFLICT_FIX.md
Normal file
865
docs/SKILL_NAME_CONFLICT_FIX.md
Normal file
@@ -0,0 +1,865 @@
|
||||
# 技能名称冲突修复 - 代码改动文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细记录了修复 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/src/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/src/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/src/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) => (
|
||||
<Item className="w-full" variant="outline" key={skill.name}>
|
||||
{/* ... */}
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
enableSkill({ skillName: skill.name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</Item>
|
||||
))}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```typescript
|
||||
{filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
<Item
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
key={`${skill.category}:${skill.name}`}
|
||||
>
|
||||
{/* ... */}
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
enableSkill({
|
||||
skillName: skill.name,
|
||||
enabled: checked,
|
||||
category: skill.category,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Item>
|
||||
))}
|
||||
```
|
||||
|
||||
**改动说明**:
|
||||
- **关键改动**: 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/src/config/extensions_config.py`: +1 方法,修改 1 方法
|
||||
- `backend/src/skills/loader.py`: +重复检查逻辑
|
||||
- `backend/src/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
|
||||
@@ -4,7 +4,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { UseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
@@ -63,10 +63,14 @@ export default function ChatPage() {
|
||||
}
|
||||
return t.inputBox.createSkillPrompt;
|
||||
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
|
||||
const lastInitialValueRef = useRef<string | undefined>(undefined);
|
||||
const setInputRef = useRef(promptInputController.textInput.setInput);
|
||||
setInputRef.current = promptInputController.textInput.setInput;
|
||||
useEffect(() => {
|
||||
if (inputInitialValue) {
|
||||
if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) {
|
||||
lastInitialValueRef.current = inputInitialValue;
|
||||
setTimeout(() => {
|
||||
promptInputController.textInput.setInput(inputInitialValue);
|
||||
setInputRef.current(inputInitialValue);
|
||||
const textarea = document.querySelector("textarea");
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
@@ -75,7 +79,7 @@ export default function ChatPage() {
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [inputInitialValue, promptInputController.textInput]);
|
||||
}, [inputInitialValue]);
|
||||
const isNewThread = useMemo(
|
||||
() => threadIdFromPath === "new",
|
||||
[threadIdFromPath],
|
||||
|
||||
Reference in New Issue
Block a user