diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py
index 2660170..fe03d14 100644
--- a/backend/src/agents/lead_agent/prompt.py
+++ b/backend/src/agents/lead_agent/prompt.py
@@ -259,9 +259,17 @@ You have access to skills that provide optimized workflows for specific tasks. E
- Clear and Concise: Avoid over-formatting unless requested
- Natural Tone: Use paragraphs and prose, not bullet points by default
- Action-Oriented: Focus on delivering results, not explaining processes
-- Citations: Use `[citation:Title](URL)` format for external sources
+
+- When to Use: After web_search, include citations if applicable
+- Format: Use Markdown link format `[citation:TITLE](URL)`
+- Example:
+```markdown
+The key AI trends for 2026 include enhanced reasoning capabilities and multimodal integration [citation:AI Trends 2026](https://techcrunch.com/ai-trends). Recent breakthroughs in language models have also accelerated progress [citation:OpenAI Research](https://openai.com/research).
+```
+
+
- **Clarification First**: ALWAYS clarify unclear/missing/ambiguous requirements BEFORE starting work - never assume or guess
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
diff --git a/docs/SKILL_NAME_CONFLICT_FIX.md b/docs/SKILL_NAME_CONFLICT_FIX.md
new file mode 100644
index 0000000..2103401
--- /dev/null
+++ b/docs/SKILL_NAME_CONFLICT_FIX.md
@@ -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) => (
+ -
+ {/* ... */}
+
+ 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/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
diff --git a/frontend/package.json b/frontend/package.json
index e936c34..46ca46a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -73,6 +73,7 @@
"react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1",
"rehype-katex": "^7.0.1",
+ "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 9fc1f0c..cb04b35 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -182,6 +182,9 @@ importers:
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
+ rehype-raw:
+ specifier: ^7.0.0
+ version: 7.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
index c098626..fb66c38 100644
--- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx
+++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx
@@ -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(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],
diff --git a/frontend/src/components/workspace/settings/memory-settings-page.tsx b/frontend/src/components/workspace/settings/memory-settings-page.tsx
index a5225e6..831aa38 100644
--- a/frontend/src/components/workspace/settings/memory-settings-page.tsx
+++ b/frontend/src/components/workspace/settings/memory-settings-page.tsx
@@ -31,6 +31,26 @@ function confidenceToLevelKey(confidence: unknown): {
return { key: "normal", value };
}
+function formatMemorySection(
+ title: string,
+ summary: string,
+ updatedAt: string | undefined,
+ t: ReturnType["t"],
+): string {
+ const content =
+ summary.trim() ||
+ `${t.settings.memory.markdown.empty}`;
+ return [
+ `### ${title}`,
+ content,
+ "",
+ updatedAt &&
+ `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(updatedAt)}\``,
+ ]
+ .filter(Boolean)
+ .join("\n");
+}
+
function memoryToMarkdown(
memory: UserMemory,
t: ReturnType["t"],
@@ -44,65 +64,61 @@ function memoryToMarkdown(
parts.push(`\n## ${t.settings.memory.markdown.userContext}`);
parts.push(
- [
- `### ${t.settings.memory.markdown.work}`,
- memory.user.workContext.summary || "-",
- "",
- memory.user.workContext.updatedAt &&
- `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.user.workContext.updatedAt)}\``,
- ].join("\n"),
+ formatMemorySection(
+ t.settings.memory.markdown.work,
+ memory.user.workContext.summary,
+ memory.user.workContext.updatedAt,
+ t,
+ ),
);
parts.push(
- [
- `### ${t.settings.memory.markdown.personal}`,
- memory.user.personalContext.summary || "-",
- "",
- memory.user.personalContext.updatedAt &&
- `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.user.personalContext.updatedAt)}\``,
- ].join("\n"),
+ formatMemorySection(
+ t.settings.memory.markdown.personal,
+ memory.user.personalContext.summary,
+ memory.user.personalContext.updatedAt,
+ t,
+ ),
);
parts.push(
- [
- `### ${t.settings.memory.markdown.topOfMind}`,
- memory.user.topOfMind.summary || "-",
- "",
- memory.user.topOfMind.updatedAt &&
- `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.user.topOfMind.updatedAt)}\``,
- ].join("\n"),
+ formatMemorySection(
+ t.settings.memory.markdown.topOfMind,
+ memory.user.topOfMind.summary,
+ memory.user.topOfMind.updatedAt,
+ t,
+ ),
);
parts.push(`\n## ${t.settings.memory.markdown.historyBackground}`);
parts.push(
- [
- `### ${t.settings.memory.markdown.recentMonths}`,
- memory.history.recentMonths.summary || "-",
- "",
- memory.history.recentMonths.updatedAt &&
- `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.history.recentMonths.updatedAt)}\``,
- ].join("\n"),
+ formatMemorySection(
+ t.settings.memory.markdown.recentMonths,
+ memory.history.recentMonths.summary,
+ memory.history.recentMonths.updatedAt,
+ t,
+ ),
);
parts.push(
- [
- `### ${t.settings.memory.markdown.earlierContext}`,
- memory.history.earlierContext.summary || "-",
- "",
- memory.history.earlierContext.updatedAt &&
- `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.history.earlierContext.updatedAt)}\``,
- ].join("\n"),
+ formatMemorySection(
+ t.settings.memory.markdown.earlierContext,
+ memory.history.earlierContext.summary,
+ memory.history.earlierContext.updatedAt,
+ t,
+ ),
);
parts.push(
- [
- `### ${t.settings.memory.markdown.longTermBackground}`,
- memory.history.longTermBackground.summary || "-",
- "",
- memory.history.longTermBackground.updatedAt &&
- `> ${t.settings.memory.markdown.updatedAt}: \`${formatTimeAgo(memory.history.longTermBackground.updatedAt)}\``,
- ].join("\n"),
+ formatMemorySection(
+ t.settings.memory.markdown.longTermBackground,
+ memory.history.longTermBackground.summary,
+ memory.history.longTermBackground.updatedAt,
+ t,
+ ),
);
parts.push(`\n## ${t.settings.memory.markdown.facts}`);
if (memory.facts.length === 0) {
- parts.push(`_${t.settings.memory.markdown.empty}_`);
+ parts.push(
+ `${t.settings.memory.markdown.empty}`,
+ );
} else {
parts.push(
[
diff --git a/frontend/src/components/workspace/settings/skill-settings-page.tsx b/frontend/src/components/workspace/settings/skill-settings-page.tsx
index 24398fa..c564256 100644
--- a/frontend/src/components/workspace/settings/skill-settings-page.tsx
+++ b/frontend/src/components/workspace/settings/skill-settings-page.tsx
@@ -115,20 +115,20 @@ function SkillSettingsList({
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
+ const { t } = useI18n();
return (
- No agent skill yet
+ {t.settings.skills.emptyTitle}
- Put your agent skill folders under the `/skills/custom` folder under
- the root folder of DeerFlow.
+ {t.settings.skills.emptyDescription}
-
+
);
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index ac53d66..1bc60a9 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -243,7 +243,7 @@ export const enUS: Translations = {
longTermBackground: "Long-term background",
updatedAt: "Updated at",
facts: "Facts",
- empty: "Empty",
+ empty: "(empty)",
table: {
category: "Category",
confidence: "Confidence",
@@ -282,6 +282,10 @@ export const enUS: Translations = {
description:
"Manage the configuration and enabled status of the agent skills.",
createSkill: "Create skill",
+ emptyTitle: "No agent skill yet",
+ emptyDescription:
+ "Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.",
+ emptyButton: "Create Your First Skill",
},
notification: {
title: "Notification",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 843f517..7213efa 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -225,6 +225,9 @@ export interface Translations {
title: string;
description: string;
createSkill: string;
+ emptyTitle: string;
+ emptyDescription: string;
+ emptyButton: string;
};
notification: {
title: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 4f03539..a3d399b 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -275,6 +275,10 @@ export const zhCN: Translations = {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
createSkill: "新建技能",
+ emptyTitle: "还没有技能",
+ emptyDescription:
+ "将你的 Agent Skill 文件夹放在 DeerFlow 根目录下的 `/skills/custom` 文件夹中。",
+ emptyButton: "创建你的第一个技能",
},
notification: {
title: "通知",
diff --git a/frontend/src/core/streamdown/plugins.ts b/frontend/src/core/streamdown/plugins.ts
index d829a53..e921403 100644
--- a/frontend/src/core/streamdown/plugins.ts
+++ b/frontend/src/core/streamdown/plugins.ts
@@ -1,4 +1,5 @@
import rehypeKatex from "rehype-katex";
+import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import type { StreamdownProps } from "streamdown";
@@ -11,6 +12,7 @@ export const streamdownPlugins = {
[remarkMath, { singleDollarTextMath: true }],
] as StreamdownProps["remarkPlugins"],
rehypePlugins: [
+ rehypeRaw,
[rehypeKatex, { output: "html" }],
] as StreamdownProps["rehypePlugins"],
};