mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-05 05:16:03 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1347,6 +1347,13 @@ const zhHans = {
|
||||
selectFromSidebar: '从侧边栏选择一个技能',
|
||||
dangerZone: '危险区域',
|
||||
dangerZoneDescription: '不可逆的操作',
|
||||
files: '文件',
|
||||
noFiles: '暂无文件',
|
||||
loadFilesError: '加载文件失败:',
|
||||
readFileError: '读取文件失败:',
|
||||
saveFile: '保存文件',
|
||||
saveFileSuccess: '文件保存成功',
|
||||
saveFileError: '保存文件失败:',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: '通过引导步骤创建机器人',
|
||||
|
||||
Reference in New Issue
Block a user