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"], };