From 77a85c5c23ff555c84a97c3616779d63b4a80a6c Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 21:26:03 +0800 Subject: [PATCH] feat(skill): add skill file browsing capability - Add API endpoints for listing/reading/writing skill files - Add FileTree component in SkillForm for directory browsing - Users can now view scripts/, references/, assets/ directories - Files can be selected and edited in the instructions textarea - Add translations for new file browsing features Co-Authored-By: Claude Opus 4.7 --- .../pkg/api/http/controller/groups/skills.py | 54 ++++-- .../components/skill-form/SkillForm.tsx | 183 +++++++++++++++++- web/src/app/infra/http/BackendClient.ts | 46 +++++ web/src/i18n/locales/en-US.ts | 7 + web/src/i18n/locales/zh-Hans.ts | 7 + 5 files changed, 279 insertions(+), 18 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 73350246..62403163 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -48,22 +48,50 @@ class SkillsRouterGroup(group.RouterGroup): except ValueError as exc: return self.http_status(400, -1, str(exc)) + @self.route('//files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_skill_files(skill_name: str) -> quart.Response: + """List files in skill package directory.""" + path = quart.request.args.get('path', '.').strip() + include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true' + + try: + result = await self.ap.skill_service.list_skill_files( + skill_name, + path=path, + include_hidden=include_hidden, + ) + return self.success(data=result) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('//files/', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response: + """Read or write a file in skill package.""" + if quart.request.method == 'GET': + try: + result = await self.ap.skill_service.read_skill_file(skill_name, path) + return self.success(data=result) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + + # PUT - write file + data = await quart.request.json + content = data.get('content', '') + if content is None: + return self.http_status(400, -1, 'Missing required field: content') + + try: + result = await self.ap.skill_service.write_skill_file(skill_name, path, content) + return self.success(data=result) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + @self.route('//preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def preview_skill(skill_name: str) -> quart.Response: - runtime_data = self.ap.skill_mgr.get_skill_runtime_data(skill_name) - if not runtime_data: + skill = self.ap.skill_mgr.get_skill_by_name(skill_name) + if not skill: return self.http_status(404, -1, 'Skill not found') - return self.success(data={'instructions': runtime_data['instructions']}) - - @self.route('/index', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) - async def get_skill_index() -> quart.Response: - pipeline_uuid = quart.request.args.get('pipeline_uuid') - bound_skills = quart.request.args.getlist('bound_skills') - skill_index = self.ap.skill_mgr.get_skill_index( - pipeline_uuid=pipeline_uuid, - bound_skills=bound_skills if bound_skills else None, - ) - return self.success(data={'index': skill_index}) + return self.success(data={'instructions': skill.get('instructions', '')}) @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def install_skill_from_github() -> quart.Response: 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 93b32171..75419e42 100644 --- a/web/src/app/home/skills/components/skill-form/SkillForm.tsx +++ b/web/src/app/home/skills/components/skill-form/SkillForm.tsx @@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; -import { FolderSearch, ChevronDown, ChevronRight } from 'lucide-react'; +import { FolderSearch, ChevronDown, ChevronRight, File, Folder, FolderOpen, RefreshCw } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Skill } from '@/app/infra/entities/api'; import { toast } from 'sonner'; @@ -20,6 +20,139 @@ interface SkillFormProps { export interface SkillFormDraft { skill: Partial; showAdvanced: boolean; + selectedFile?: string; +} + +interface FileEntry { + path: string; + name: string; + is_dir: boolean; + size: number | null; +} + +interface FileTreeProps { + skillName: string; + onFileSelect: (path: string, content: string) => void; +} + +function FileTree({ skillName, onFileSelect }: FileTreeProps) { + const { t } = useTranslation(); + const [basePath, setBasePath] = useState('.'); + const [entries, setEntries] = useState([]); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [selectedPath, setSelectedPath] = useState(null); + + const loadFiles = useCallback(async (path: string = '.') => { + setLoading(true); + try { + const result = await httpClient.listSkillFiles(skillName, path); + setBasePath(result.base_path); + setEntries(result.entries); + } catch (error) { + console.error('Failed to load skill files:', error); + toast.error(t('skills.loadFilesError') + String(error)); + } finally { + setLoading(false); + } + }, [skillName, t]); + + useEffect(() => { + if (skillName) { + loadFiles('.'); + } + }, [skillName, loadFiles]); + + const toggleDir = async (dirPath: string) => { + const newExpanded = new Set(expandedDirs); + if (newExpanded.has(dirPath)) { + newExpanded.delete(dirPath); + setExpandedDirs(newExpanded); + } else { + newExpanded.add(dirPath); + setExpandedDirs(newExpanded); + loadFiles(dirPath); + } + }; + + const handleFileClick = async (filePath: string) => { + setSelectedPath(filePath); + try { + const result = await httpClient.readSkillFile(skillName, filePath); + onFileSelect(filePath, result.content); + } catch (error) { + console.error('Failed to read file:', error); + toast.error(t('skills.readFileError') + String(error)); + } + }; + + const getFullPath = (entry: FileEntry): string => { + if (basePath === '.' || basePath === '') { + return entry.path; + } + return `${basePath}/${entry.path}`; + }; + + return ( +
+
+ {t('skills.files')} + +
+
+ {entries.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`} + + )} +
+ ); + })} +
+
+ ); } const emptySkillDraft: SkillFormDraft = { @@ -49,6 +182,7 @@ export default function SkillForm({ const [showAdvanced, setShowAdvanced] = useState( initialDraftRef.current.showAdvanced, ); + const [selectedFile, setSelectedFile] = useState(null); const loadSkill = useCallback( async (skillName: string) => { @@ -74,8 +208,8 @@ export default function SkillForm({ useEffect(() => { if (initSkillName) return; - onDraftChange?.({ skill, showAdvanced }); - }, [initSkillName, onDraftChange, skill, showAdvanced]); + onDraftChange?.({ skill, showAdvanced, selectedFile: selectedFile || undefined }); + }, [initSkillName, onDraftChange, skill, showAdvanced, selectedFile]); async function scanDirectory() { const path = skill.package_root?.trim(); @@ -103,6 +237,26 @@ export default function SkillForm({ } } + const handleFileSelect = (path: string, content: string) => { + setSelectedFile(path); + // If selecting SKILL.md, update instructions + if (path === 'SKILL.md' || path.endsWith('/SKILL.md')) { + setSkill((prev) => ({ ...prev, instructions: content })); + } + }; + + const handleSaveFile = async () => { + if (!initSkillName || !selectedFile) return; + + try { + await httpClient.writeSkillFile(initSkillName, selectedFile, skill.instructions || ''); + toast.success(t('skills.saveFileSuccess')); + } catch (error) { + console.error('Failed to save file:', error); + toast.error(t('skills.saveFileError') + String(error)); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -187,8 +341,17 @@ export default function SkillForm({ /> + {/* File tree for existing skills */} + {initSkillName && ( +
+ +
+ )} +
- +