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 <noreply@anthropic.com>
This commit is contained in:
huanghuoguoguo
2026-05-13 21:26:03 +08:00
parent 892556da2a
commit 77a85c5c23
5 changed files with 279 additions and 18 deletions

View File

@@ -48,22 +48,50 @@ class SkillsRouterGroup(group.RouterGroup):
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route('/<skill_name>/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('/<skill_name>/files/<path:path>', 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('/<skill_name>/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:

View File

@@ -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<Skill>;
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<FileEntry[]>([]);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(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 (
<div className="border rounded-md p-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{t('skills.files')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => loadFiles('.')}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{entries.length === 0 && !loading && (
<div className="text-sm text-muted-foreground py-2">
{t('skills.noFiles')}
</div>
)}
{entries.map((entry) => {
const fullPath = getFullPath(entry);
const isExpanded = expandedDirs.has(fullPath);
const isSelected = selectedPath === fullPath;
return (
<div
key={fullPath}
className={`flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted ${
isSelected ? 'bg-muted' : ''
}`}
onClick={() => entry.is_dir ? toggleDir(fullPath) : handleFileClick(fullPath)}
>
{entry.is_dir ? (
<>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-blue-500" />
) : (
<Folder className="h-4 w-4 text-blue-500" />
)}
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</>
) : (
<File className="h-4 w-4 text-gray-500" />
)}
<span className="text-sm truncate">{entry.name}</span>
{!entry.is_dir && entry.size !== null && (
<span className="text-xs text-muted-foreground ml-auto">
{entry.size > 1024 ? `${Math.round(entry.size / 1024)}KB` : `${entry.size}B`}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
const emptySkillDraft: SkillFormDraft = {
@@ -49,6 +182,7 @@ export default function SkillForm({
const [showAdvanced, setShowAdvanced] = useState(
initialDraftRef.current.showAdvanced,
);
const [selectedFile, setSelectedFile] = useState<string | null>(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({
/>
</div>
{/* File tree for existing skills */}
{initSkillName && (
<div className="space-y-2">
<FileTree skillName={initSkillName} onFileSelect={handleFileSelect} />
</div>
)}
<div className="space-y-2">
<Label htmlFor="instructions">{t('skills.skillInstructions')}</Label>
<Label htmlFor="instructions">
{selectedFile ? `${t('skills.skillInstructions')} (${selectedFile})` : t('skills.skillInstructions')}
</Label>
<Textarea
id="instructions"
value={skill.instructions || ''}
@@ -197,6 +360,16 @@ export default function SkillForm({
rows={16}
className="font-mono text-sm"
/>
{selectedFile && selectedFile !== 'SKILL.md' && !selectedFile.endsWith('/SKILL.md') && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSaveFile}
>
{t('skills.saveFile')}
</Button>
)}
</div>
<div className="space-y-3">
@@ -251,4 +424,4 @@ export default function SkillForm({
</div>
</form>
);
}
}

View File

@@ -1272,6 +1272,52 @@ export class BackendClient extends BaseHttpClient {
}> {
return this.get('/api/v1/skills/scan', { path });
}
public listSkillFiles(
skillName: string,
path: string = '.',
includeHidden: boolean = false,
): Promise<{
skill: { name: string };
base_path: string;
entries: Array<{
path: string;
name: string;
is_dir: boolean;
size: number | null;
}>;
truncated: boolean;
}> {
return this.get(`/api/v1/skills/${skillName}/files`, {
path,
include_hidden: includeHidden,
});
}
public readSkillFile(
skillName: string,
filePath: string,
): Promise<{
skill: { name: string };
path: string;
content: string;
}> {
return this.get(`/api/v1/skills/${skillName}/files/${filePath}`);
}
public writeSkillFile(
skillName: string,
filePath: string,
content: string,
): Promise<{
skill: { name: string };
path: string;
bytes_written: number;
}> {
return this.put(`/api/v1/skills/${skillName}/files/${filePath}`, {
content,
});
}
}
export interface SurveyQuestion {

View File

@@ -1404,6 +1404,13 @@ const enUS = {
selectFromSidebar: 'Select a skill from the sidebar',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
files: 'Files',
noFiles: 'No files found',
loadFilesError: 'Failed to load files: ',
readFileError: 'Failed to read file: ',
saveFile: 'Save File',
saveFileSuccess: 'File saved successfully',
saveFileError: 'Failed to save file: ',
},
wizard: {
sidebarDescription: 'Create a bot with guided steps',

View File

@@ -1347,6 +1347,13 @@ const zhHans = {
selectFromSidebar: '从侧边栏选择一个技能',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
files: '文件',
noFiles: '暂无文件',
loadFilesError: '加载文件失败:',
readFileError: '读取文件失败:',
saveFile: '保存文件',
saveFileSuccess: '文件保存成功',
saveFileError: '保存文件失败:',
},
wizard: {
sidebarDescription: '通过引导步骤创建机器人',