From 17ae6950aa9b2bf0292a767fa90c3199fe1be867 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 22:08:58 +0800 Subject: [PATCH] fix(skill): improve file browsing and fix path handling - Fix nested directory display in skill file tree (preserve root entries) - Fix file content display when clicking files in skill browser - Add skill manager and tool manager as proper package modules - Separate fileContent state to allow editing non-SKILL.md files Co-Authored-By: Claude Opus 4.7 --- src/langbot/pkg/provider/tools/toolmgr.py | 124 +++++++++++ src/langbot/pkg/skill/manager.py | 192 ++++++++++++++++++ .../components/skill-form/SkillForm.tsx | 153 ++++++++------ 3 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 src/langbot/pkg/provider/tools/toolmgr.py create mode 100644 src/langbot/pkg/skill/manager.py diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py new file mode 100644 index 00000000..f84e2d2b --- /dev/null +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import typing +from typing import TYPE_CHECKING + +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +from langbot_plugin.api.entities.events import pipeline_query + +if TYPE_CHECKING: + from ...core import app + from langbot.pkg.provider.tools.loaders import ( + mcp as mcp_loader, + native as native_loader, + plugin as plugin_loader, + skill_authoring as skill_authoring_loader, + ) + + +class ToolManager: + """LLM工具管理器""" + + ap: app.Application + + native_tool_loader: native_loader.NativeToolLoader + plugin_tool_loader: plugin_loader.PluginToolLoader + mcp_tool_loader: mcp_loader.MCPLoader + skill_tool_loader: skill_authoring_loader.SkillToolLoader + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + from langbot.pkg.utils import importutil + from langbot.pkg.provider.tools import loaders + from langbot.pkg.provider.tools.loaders import ( + mcp as mcp_loader, + native as native_loader, + plugin as plugin_loader, + skill_authoring as skill_authoring_loader, + ) + + importutil.import_modules_in_pkg(loaders) + + self.native_tool_loader = native_loader.NativeToolLoader(self.ap) + await self.native_tool_loader.initialize() + + # Log native (sandbox) tool availability once at startup + box_service = getattr(self.ap, 'box_service', None) + if box_service and getattr(box_service, 'available', False): + self.ap.logger.info('Native sandbox tools (exec/read/write/edit/glob/grep) are available.') + else: + self.ap.logger.warning( + 'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. ' + 'Box runtime is not connected — the LLM will not have access to code execution tools.' + ) + + self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap) + await self.plugin_tool_loader.initialize() + self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) + await self.mcp_tool_loader.initialize() + self.skill_tool_loader = skill_authoring_loader.SkillToolLoader(self.ap) + await self.skill_tool_loader.initialize() + + async def get_all_tools( + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = False, + ) -> list[resource_tool.LLMTool]: + all_functions: list[resource_tool.LLMTool] = [] + + all_functions.extend(await self.native_tool_loader.get_tools()) + if include_skill_authoring: + all_functions.extend(await self.skill_tool_loader.get_tools()) + all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) + all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers)) + + return all_functions + + async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: + tools = [] + + for function in use_funcs: + function_schema = { + 'type': 'function', + 'function': { + 'name': function.name, + 'description': function.description, + 'parameters': function.parameters, + }, + } + tools.append(function_schema) + + return tools + + async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list: + tools = [] + + for function in use_funcs: + function_schema = { + 'name': function.name, + 'description': function.description, + 'input_schema': function.parameters, + } + tools.append(function_schema) + + return tools + + async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: + if await self.native_tool_loader.has_tool(name): + return await self.native_tool_loader.invoke_tool(name, parameters, query) + if await self.plugin_tool_loader.has_tool(name): + return await self.plugin_tool_loader.invoke_tool(name, parameters, query) + if await self.mcp_tool_loader.has_tool(name): + return await self.mcp_tool_loader.invoke_tool(name, parameters, query) + if await self.skill_authoring_tool_loader.has_tool(name): + return await self.skill_authoring_tool_loader.invoke_tool(name, parameters, query) + raise ValueError(f'未找到工具: {name}') + + async def shutdown(self): + await self.native_tool_loader.shutdown() + await self.plugin_tool_loader.shutdown() + await self.mcp_tool_loader.shutdown() + await self.skill_authoring_tool_loader.shutdown() diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py new file mode 100644 index 00000000..1db8afba --- /dev/null +++ b/src/langbot/pkg/skill/manager.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import datetime as dt +import os +import shutil +import typing + +from ..core import app +from .utils import parse_frontmatter +from ..utils import paths + +if typing.TYPE_CHECKING: + pass + + +class SkillManager: + """Skill manager backed by filesystem packages. + + Skills are loaded from two sources: + 1. Builtin skills: templates/skills/ (shipped with LangBot) + 2. User skills: data/skills/ (created by users) + + Skills are activated through the `activate` tool (Tool Call mechanism), + aligned with Claude Code's design. This protects KV Cache and follows + industry standard. + """ + + ap: app.Application + skills: dict[str, dict] + + def __init__(self, ap: app.Application): + self.ap = ap + self.skills = {} + + async def initialize(self): + await self.reload_skills() + + async def reload_skills(self): + """Reload all skills. + + Builtin skills (templates/skills/) are copied to data/skills/ on first run, + then all skills are loaded from data/skills/. + + NOTE: This performs a full scan. For registering a single new skill, + consider adding it directly to self.skills instead of reloading all. + Current implementation is acceptable for typical skill counts (<50). + """ + self.skills = {} + + # Ensure data/skills/ exists + managed_root = self.get_managed_skills_root() + os.makedirs(managed_root, exist_ok=True) + + # Copy builtin skills to data/skills/ if not already present + builtin_root = self.get_builtin_skills_root() + if os.path.isdir(builtin_root): + for package_root, entry_file in self._discover_skill_directories(builtin_root): + skill_data = { + 'package_root': package_root, + 'entry_file': entry_file, + } + if not self._load_skill_file(skill_data): + continue + + skill_name = skill_data['name'] + target_path = os.path.join(managed_root, skill_name) + + # Only copy if target doesn't exist (preserve user modifications) + if not os.path.exists(target_path): + shutil.copytree(package_root, target_path) + self.ap.logger.info(f'Copied builtin skill "{skill_name}" to data/skills/') + + # Load all skills from data/skills/ + if os.path.isdir(managed_root): + for package_root, entry_file in self._discover_skill_directories(managed_root): + skill_data = { + 'package_root': package_root, + 'entry_file': entry_file, + } + if not self._load_skill_file(skill_data): + continue + + skill_name = skill_data['name'] + self.skills[skill_name] = skill_data + + self.ap.logger.info(f'Loaded {len(self.skills)} skills') + + def refresh_skill_from_disk(self, skill_name: str) -> bool: + """Refresh a single skill from disk.""" + if not skill_name: + return False + + skill_data = self.skills.get(skill_name) + if not skill_data: + return False + + if not self._load_skill_file(skill_data): + return False + + self.skills[skill_name] = skill_data + return True + + @staticmethod + def get_managed_skills_root() -> str: + """Get the root directory for managed user skills.""" + return paths.get_data_path('skills') + + @staticmethod + def get_builtin_skills_root() -> str: + """Get the root directory for builtin skills (templates/skills/).""" + return paths.get_resource_path('templates/skills') + + def _discover_skill_directories(self, root_path: str, max_depth: int = 6) -> list[tuple[str, str]]: + """Discover all skill directories under root_path.""" + discovered: list[tuple[str, str]] = [] + root_path = os.path.realpath(os.path.abspath(root_path)) + root_depth = root_path.rstrip(os.sep).count(os.sep) + + for current_root, dirs, _files in os.walk(root_path): + current_root = os.path.realpath(current_root) + depth = current_root.rstrip(os.sep).count(os.sep) - root_depth + if depth > max_depth: + dirs[:] = [] + continue + + found = self._find_skill_entry(current_root) + if found is not None: + discovered.append(found) + dirs[:] = [] + + discovered.sort(key=lambda item: item[0]) + return discovered + + @staticmethod + def _find_skill_entry(path: str) -> tuple[str, str] | None: + """Find SKILL.md entry file in a directory.""" + for candidate in ('SKILL.md', 'skill.md'): + if os.path.isfile(os.path.join(path, candidate)): + return path, candidate + return None + + def _load_skill_file(self, skill_data: dict) -> bool: + """Load skill data from SKILL.md file.""" + package_root = self._normalize_package_root(skill_data.get('package_root', '')) + entry_file = skill_data.get('entry_file', 'SKILL.md') + if not package_root: + self.ap.logger.warning('Skill package_root is empty, skipping') + return False + + entry_path = os.path.join(package_root, entry_file) + try: + with open(entry_path, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + self.ap.logger.warning(f'Skill entry file not found: {entry_path}, skipping') + return False + except OSError as exc: + self.ap.logger.warning(f'Failed to read skill entry file {entry_path}: {exc}, skipping') + return False + + metadata, instructions = parse_frontmatter(content) + name = str(metadata.get('name') or os.path.basename(os.path.normpath(package_root))).strip() + if not name: + self.ap.logger.warning(f'Skill at {package_root} has no valid name, skipping') + return False + + stat = os.stat(entry_path) + skill_data.clear() + skill_data.update( + { + 'name': name, + 'display_name': str(metadata.get('display_name') or name).strip(), + 'description': str(metadata.get('description') or '').strip(), + 'instructions': instructions, + 'raw_content': content, + 'package_root': package_root, + 'entry_file': entry_file, + 'created_at': dt.datetime.fromtimestamp(stat.st_ctime, tz=dt.timezone.utc).isoformat(), + 'updated_at': dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc).isoformat(), + } + ) + return True + + @staticmethod + def _normalize_package_root(package_root: str) -> str: + if not package_root: + return '' + return os.path.realpath(os.path.abspath(package_root)) + + def get_skill_by_name(self, name: str) -> dict | None: + """Get skill data by name.""" + return self.skills.get(name) \ No newline at end of file diff --git a/web/src/app/home/skills/components/skill-form/SkillForm.tsx b/web/src/app/home/skills/components/skill-form/SkillForm.tsx index 75419e42..199b4bca 100644 --- a/web/src/app/home/skills/components/skill-form/SkillForm.tsx +++ b/web/src/app/home/skills/components/skill-form/SkillForm.tsx @@ -30,6 +30,12 @@ interface FileEntry { size: number | null; } +interface DirectoryContent { + path: string; + entries: FileEntry[]; + loading: boolean; +} + interface FileTreeProps { skillName: string; onFileSelect: (path: string, content: string) => void; @@ -37,18 +43,17 @@ interface FileTreeProps { function FileTree({ skillName, onFileSelect }: FileTreeProps) { const { t } = useTranslation(); - const [basePath, setBasePath] = useState('.'); - const [entries, setEntries] = useState([]); + const [rootEntries, setRootEntries] = useState([]); const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [dirContents, setDirContents] = useState>(new Map()); const [loading, setLoading] = useState(false); const [selectedPath, setSelectedPath] = useState(null); - const loadFiles = useCallback(async (path: string = '.') => { + const loadRootFiles = useCallback(async () => { setLoading(true); try { - const result = await httpClient.listSkillFiles(skillName, path); - setBasePath(result.base_path); - setEntries(result.entries); + const result = await httpClient.listSkillFiles(skillName, '.'); + setRootEntries(result.entries); } catch (error) { console.error('Failed to load skill files:', error); toast.error(t('skills.loadFilesError') + String(error)); @@ -57,11 +62,30 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { } }, [skillName, t]); + const loadDirFiles = useCallback(async (dirPath: string) => { + setDirContents(prev => { + const newMap = new Map(prev); + newMap.set(dirPath, []); // Clear while loading + return newMap; + }); + try { + const result = await httpClient.listSkillFiles(skillName, dirPath); + setDirContents(prev => { + const newMap = new Map(prev); + newMap.set(dirPath, result.entries); + return newMap; + }); + } catch (error) { + console.error('Failed to load directory files:', error); + toast.error(t('skills.loadFilesError') + String(error)); + } + }, [skillName, t]); + useEffect(() => { if (skillName) { - loadFiles('.'); + loadRootFiles(); } - }, [skillName, loadFiles]); + }, [skillName, loadRootFiles]); const toggleDir = async (dirPath: string) => { const newExpanded = new Set(expandedDirs); @@ -71,7 +95,7 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { } else { newExpanded.add(dirPath); setExpandedDirs(newExpanded); - loadFiles(dirPath); + loadDirFiles(dirPath); } }; @@ -86,11 +110,49 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) { } }; - const getFullPath = (entry: FileEntry): string => { - if (basePath === '.' || basePath === '') { - return entry.path; - } - return `${basePath}/${entry.path}`; + const renderEntry = (entry: FileEntry, depth: number = 0): React.ReactNode => { + const isExpanded = expandedDirs.has(entry.path); + const isSelected = selectedPath === entry.path; + + return ( +
+
entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path)} + > + {entry.is_dir ? ( + <> + {isExpanded ? ( + + ) : ( + + )} + {isExpanded ? ( + + ) : ( + + )} + + ) : ( + + )} + {entry.name} + {!entry.is_dir && entry.size !== null && ( + + {entry.size > 1024 ? `${Math.round(entry.size / 1024)}KB` : `${entry.size}B`} + + )} +
+ {entry.is_dir && isExpanded && ( +
+ {(dirContents.get(entry.path) || []).map((child) => renderEntry(child, depth + 1))} +
+ )} +
+ ); }; return ( @@ -100,56 +162,19 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
- {entries.length === 0 && !loading && ( + {rootEntries.length === 0 && !loading && (
{t('skills.noFiles')}
)} - {entries.map((entry) => { - const fullPath = getFullPath(entry); - const isExpanded = expandedDirs.has(fullPath); - const isSelected = selectedPath === fullPath; - - return ( -
entry.is_dir ? toggleDir(fullPath) : handleFileClick(fullPath)} - > - {entry.is_dir ? ( - <> - {isExpanded ? ( - - ) : ( - - )} - {isExpanded ? ( - - ) : ( - - )} - - ) : ( - - )} - {entry.name} - {!entry.is_dir && entry.size !== null && ( - - {entry.size > 1024 ? `${Math.round(entry.size / 1024)}KB` : `${entry.size}B`} - - )} -
- ); - })} + {rootEntries.map((entry) => renderEntry(entry))}
); @@ -183,12 +208,14 @@ export default function SkillForm({ initialDraftRef.current.showAdvanced, ); const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(''); const loadSkill = useCallback( async (skillName: string) => { try { const resp = await httpClient.getSkill(skillName); setSkill(resp.skill); + setFileContent(resp.skill.instructions || ''); } catch (error) { console.error('Failed to load skill:', error); toast.error(t('skills.getSkillListError') + String(error)); @@ -228,6 +255,7 @@ export default function SkillForm({ package_root: result.package_root, instructions: result.instructions, })); + setFileContent(result.instructions); toast.success(t('skills.scanSuccess')); } catch (error) { console.error('Failed to scan directory:', error); @@ -239,17 +267,26 @@ export default function SkillForm({ const handleFileSelect = (path: string, content: string) => { setSelectedFile(path); - // If selecting SKILL.md, update instructions + setFileContent(content); + // If selecting SKILL.md, also sync to skill.instructions if (path === 'SKILL.md' || path.endsWith('/SKILL.md')) { setSkill((prev) => ({ ...prev, instructions: content })); } }; + const handleContentChange = (content: string) => { + setFileContent(content); + // If editing SKILL.md, sync to skill.instructions + if (selectedFile === 'SKILL.md' || selectedFile?.endsWith('/SKILL.md')) { + setSkill((prev) => ({ ...prev, instructions: content })); + } + }; + const handleSaveFile = async () => { if (!initSkillName || !selectedFile) return; try { - await httpClient.writeSkillFile(initSkillName, selectedFile, skill.instructions || ''); + await httpClient.writeSkillFile(initSkillName, selectedFile, fileContent); toast.success(t('skills.saveFileSuccess')); } catch (error) { console.error('Failed to save file:', error); @@ -354,8 +391,8 @@ export default function SkillForm({