mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 21:36:06 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -30,6 +30,12 @@ interface FileEntry {
|
|||||||
size: number | null;
|
size: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectoryContent {
|
||||||
|
path: string;
|
||||||
|
entries: FileEntry[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface FileTreeProps {
|
interface FileTreeProps {
|
||||||
skillName: string;
|
skillName: string;
|
||||||
onFileSelect: (path: string, content: string) => void;
|
onFileSelect: (path: string, content: string) => void;
|
||||||
@@ -37,18 +43,17 @@ interface FileTreeProps {
|
|||||||
|
|
||||||
function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [basePath, setBasePath] = useState('.');
|
const [rootEntries, setRootEntries] = useState<FileEntry[]>([]);
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
||||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||||
|
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadFiles = useCallback(async (path: string = '.') => {
|
const loadRootFiles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.listSkillFiles(skillName, path);
|
const result = await httpClient.listSkillFiles(skillName, '.');
|
||||||
setBasePath(result.base_path);
|
setRootEntries(result.entries);
|
||||||
setEntries(result.entries);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load skill files:', error);
|
console.error('Failed to load skill files:', error);
|
||||||
toast.error(t('skills.loadFilesError') + String(error));
|
toast.error(t('skills.loadFilesError') + String(error));
|
||||||
@@ -57,11 +62,30 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
}
|
}
|
||||||
}, [skillName, t]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (skillName) {
|
if (skillName) {
|
||||||
loadFiles('.');
|
loadRootFiles();
|
||||||
}
|
}
|
||||||
}, [skillName, loadFiles]);
|
}, [skillName, loadRootFiles]);
|
||||||
|
|
||||||
const toggleDir = async (dirPath: string) => {
|
const toggleDir = async (dirPath: string) => {
|
||||||
const newExpanded = new Set(expandedDirs);
|
const newExpanded = new Set(expandedDirs);
|
||||||
@@ -71,7 +95,7 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
} else {
|
} else {
|
||||||
newExpanded.add(dirPath);
|
newExpanded.add(dirPath);
|
||||||
setExpandedDirs(newExpanded);
|
setExpandedDirs(newExpanded);
|
||||||
loadFiles(dirPath);
|
loadDirFiles(dirPath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,11 +110,49 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFullPath = (entry: FileEntry): string => {
|
const renderEntry = (entry: FileEntry, depth: number = 0): React.ReactNode => {
|
||||||
if (basePath === '.' || basePath === '') {
|
const isExpanded = expandedDirs.has(entry.path);
|
||||||
return entry.path;
|
const isSelected = selectedPath === entry.path;
|
||||||
}
|
|
||||||
return `${basePath}/${entry.path}`;
|
return (
|
||||||
|
<div key={entry.path}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted ${
|
||||||
|
isSelected ? 'bg-muted' : ''
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
|
onClick={() => entry.is_dir ? toggleDir(entry.path) : handleFileClick(entry.path)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
{entry.is_dir && isExpanded && (
|
||||||
|
<div>
|
||||||
|
{(dirContents.get(entry.path) || []).map((child) => renderEntry(child, depth + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,56 +162,19 @@ function FileTree({ skillName, onFileSelect }: FileTreeProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => loadFiles('.')}
|
onClick={() => loadRootFiles()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
{entries.length === 0 && !loading && (
|
{rootEntries.length === 0 && !loading && (
|
||||||
<div className="text-sm text-muted-foreground py-2">
|
<div className="text-sm text-muted-foreground py-2">
|
||||||
{t('skills.noFiles')}
|
{t('skills.noFiles')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{entries.map((entry) => {
|
{rootEntries.map((entry) => renderEntry(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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -183,12 +208,14 @@ export default function SkillForm({
|
|||||||
initialDraftRef.current.showAdvanced,
|
initialDraftRef.current.showAdvanced,
|
||||||
);
|
);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
const [fileContent, setFileContent] = useState<string>('');
|
||||||
|
|
||||||
const loadSkill = useCallback(
|
const loadSkill = useCallback(
|
||||||
async (skillName: string) => {
|
async (skillName: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.getSkill(skillName);
|
const resp = await httpClient.getSkill(skillName);
|
||||||
setSkill(resp.skill);
|
setSkill(resp.skill);
|
||||||
|
setFileContent(resp.skill.instructions || '');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load skill:', error);
|
console.error('Failed to load skill:', error);
|
||||||
toast.error(t('skills.getSkillListError') + String(error));
|
toast.error(t('skills.getSkillListError') + String(error));
|
||||||
@@ -228,6 +255,7 @@ export default function SkillForm({
|
|||||||
package_root: result.package_root,
|
package_root: result.package_root,
|
||||||
instructions: result.instructions,
|
instructions: result.instructions,
|
||||||
}));
|
}));
|
||||||
|
setFileContent(result.instructions);
|
||||||
toast.success(t('skills.scanSuccess'));
|
toast.success(t('skills.scanSuccess'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to scan directory:', error);
|
console.error('Failed to scan directory:', error);
|
||||||
@@ -239,17 +267,26 @@ export default function SkillForm({
|
|||||||
|
|
||||||
const handleFileSelect = (path: string, content: string) => {
|
const handleFileSelect = (path: string, content: string) => {
|
||||||
setSelectedFile(path);
|
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')) {
|
if (path === 'SKILL.md' || path.endsWith('/SKILL.md')) {
|
||||||
setSkill((prev) => ({ ...prev, instructions: content }));
|
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 () => {
|
const handleSaveFile = async () => {
|
||||||
if (!initSkillName || !selectedFile) return;
|
if (!initSkillName || !selectedFile) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await httpClient.writeSkillFile(initSkillName, selectedFile, skill.instructions || '');
|
await httpClient.writeSkillFile(initSkillName, selectedFile, fileContent);
|
||||||
toast.success(t('skills.saveFileSuccess'));
|
toast.success(t('skills.saveFileSuccess'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save file:', error);
|
console.error('Failed to save file:', error);
|
||||||
@@ -354,8 +391,8 @@ export default function SkillForm({
|
|||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="instructions"
|
id="instructions"
|
||||||
value={skill.instructions || ''}
|
value={fileContent}
|
||||||
onChange={(e) => setSkill({ ...skill, instructions: e.target.value })}
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
placeholder={t('skills.instructionsPlaceholder')}
|
placeholder={t('skills.instructionsPlaceholder')}
|
||||||
rows={16}
|
rows={16}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user