From eed6cfdad32520fe1e01b6e427f1c9c02daf9d9d Mon Sep 17 00:00:00 2001 From: Jasonxia007 Date: Mon, 30 Mar 2026 09:30:24 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Enable=20the=20interac?= =?UTF-8?q?tive=20creation=20tab=20in=20SkillBuildModal.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentConfig/SkillBuildModal.tsx | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx index 46307a0d2..406f3777a 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx @@ -61,13 +61,6 @@ export default function SkillBuildModal({ }: SkillBuildModalProps) { const { t } = useTranslation("common"); const [form] = Form.useForm(); - // TODO: [FEATURE] Re-enable interactive skill creation tab - // Reason: Interactive tab depends on skill_creator agent which may not be available in all deployments - // When to re-enable: - // 1. Ensure skill_creator agent is properly configured and deployed - // 2. Verify conversationService works correctly with the agent - // 3. Test the full chat-to-form workflow end-to-end - // 4. Remove this TODO and restore the interactive tab in tabItems const [activeTab, setActiveTab] = useState("upload"); const [isSubmitting, setIsSubmitting] = useState(false); const [allSkills, setAllSkills] = useState([]); @@ -128,7 +121,6 @@ export default function SkillBuildModal({ }; }, [isOpen]); - // TODO: [FEATURE] Update setActiveTab("upload") when interactive tab is re-enabled useEffect(() => { if (!isOpen) { form.resetFields(); @@ -834,19 +826,17 @@ export default function SkillBuildModal({ ); }; - // TODO: [FEATURE] Re-enable interactive skill creation tab - // See comment above for re-enablement criteria const tabItems = [ - // { - // key: "interactive", - // label: ( - // - // - // {t("skillManagement.tabs.interactive")} - // - // ), - // children: renderInteractiveTab(), - // }, + { + key: "interactive", + label: ( + + + {t("skillManagement.tabs.interactive")} + + ), + children: renderInteractiveTab(), + }, { key: "upload", label: ( From b8e058843c4b338aa9d06c6d039286aa5f179be5 Mon Sep 17 00:00:00 2001 From: Jasonxia007 Date: Wed, 1 Apr 2026 10:27:14 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20Support=20NL2Skills=20in=20fron?= =?UTF-8?q?tend=20modals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/agents/skill_creation_agent.py | 145 ++++++++++ backend/apps/skill_app.py | 227 +++++++++------ backend/prompts/skill_creation_simple_zh.yaml | 174 ++++++++++++ backend/utils/prompt_template_utils.py | 36 +++ .../agentConfig/SkillBuildModal.tsx | 212 +++++++------- frontend/services/api.ts | 2 + frontend/services/skillService.ts | 268 +++++++++++------- frontend/types/skill.ts | 32 ++- sdk/nexent/core/agents/agent_model.py | 1 + sdk/nexent/core/agents/nexent_agent.py | 1 + .../core/tools/read_skill_config_tool.py | 8 +- sdk/nexent/core/tools/read_skill_md_tool.py | 61 +++- .../core/tools/write_skill_file_tool.py | 40 ++- sdk/nexent/skills/skill_loader.py | 50 +++- sdk/nexent/skills/skill_manager.py | 80 +++++- 15 files changed, 1000 insertions(+), 337 deletions(-) create mode 100644 backend/agents/skill_creation_agent.py create mode 100644 backend/prompts/skill_creation_simple_zh.yaml diff --git a/backend/agents/skill_creation_agent.py b/backend/agents/skill_creation_agent.py new file mode 100644 index 000000000..087b8553f --- /dev/null +++ b/backend/agents/skill_creation_agent.py @@ -0,0 +1,145 @@ +"""Skill creation agent module for interactive skill generation.""" + +import logging +import threading +from typing import List + +from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig, ToolConfig +from nexent.core.agents.run_agent import agent_run_thread +from nexent.core.utils.observer import MessageObserver + +logger = logging.getLogger("skill_creation_agent") + + +def create_skill_creation_agent_config( + system_prompt: str, + model_config_list: List[ModelConfig], + local_skills_dir: str = "" +) -> AgentConfig: + """ + Create agent config for skill creation with builtin tools. + + Args: + system_prompt: Custom system prompt to replace smolagent defaults + model_config_list: List of model configurations + + Returns: + AgentConfig configured for skill creation + """ + if not model_config_list: + raise ValueError("model_config_list cannot be empty") + + first_model = model_config_list[0] + + builtin_tools = [ + ToolConfig( + class_name="WriteSkillFileTool", + name="write_skill_file", + description="Write skill file content to disk", + source="builtin", + params={"local_skills_dir": local_skills_dir}, + metadata={}, + inputs="filename: str, content: str", + output_type="bool" + ), + ToolConfig( + class_name="ReadSkillMdTool", + name="read_skill_md", + description="Read skill markdown file content", + source="builtin", + params={"local_skills_dir": local_skills_dir}, + metadata={}, + inputs="file_path: str", + output_type="str" + ) + ] + + prompt_templates = { + "system_prompt": system_prompt, + "managed_agent": { + "task": "{task}", + "report": "## {name} Report\n\n{final_answer}" + }, + "planning": { + "initial_plan": "", + "update_plan_pre_messages": "", + "update_plan_post_messages": "" + }, + "final_answer": { + "pre_messages": "", + "post_messages": "" + } + } + + return AgentConfig( + name="__skill_creator__", + description="Internal skill creator agent", + prompt_templates=prompt_templates, + tools=builtin_tools, + max_steps=5, + model_name=first_model.cite_name + ) + + +def run_skill_creation_agent( + query: str, + agent_config: AgentConfig, + model_config_list: List[ModelConfig], + observer: MessageObserver, + stop_event: threading.Event, +) -> None: + """ + Run the skill creator agent synchronously. + + Args: + query: User query for the agent + agent_config: Pre-configured agent config + model_config_list: List of model configurations + observer: Message observer for capturing agent output + stop_event: Threading event for cancellation + """ + agent_run_info = AgentRunInfo( + query=query, + model_config_list=model_config_list, + observer=observer, + agent_config=agent_config, + stop_event=stop_event + ) + + agent_run_thread(agent_run_info) + + +def create_simple_skill_from_request( + system_prompt: str, + user_prompt: str, + model_config_list: List[ModelConfig], + observer: MessageObserver, + stop_event: threading.Event, + local_skills_dir: str = "" +) -> None: + """ + Run skill creation agent to create a skill interactively. + + The agent will write the skill content to tmp.md in local_skills_dir. + Frontend should read tmp.md after agent completes to get the skill content. + + Args: + system_prompt: System prompt with skill creation instructions + user_prompt: User's skill description request + model_config_list: List of model configurations + observer: Message observer for capturing agent output + stop_event: Threading event for cancellation + local_skills_dir: Path to local skills directory for file operations + """ + agent_config = create_skill_creation_agent_config( + system_prompt=system_prompt, + model_config_list=model_config_list, + local_skills_dir=local_skills_dir + ) + + thread_agent = threading.Thread( + target=run_skill_creation_agent, + args=(user_prompt, agent_config, model_config_list, observer, stop_event) + ) + thread_agent.start() + thread_agent.join() diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py index 8bf19e8b7..2bcf4ccb1 100644 --- a/backend/apps/skill_app.py +++ b/backend/apps/skill_app.py @@ -1,18 +1,23 @@ """Skill management HTTP endpoints.""" +import asyncio import logging import os -import re +import threading from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Form, Header -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, StreamingResponse from pydantic import BaseModel from consts.exceptions import SkillException, UnauthorizedError from services.skill_service import SkillService from consts.model import SkillInstanceInfoRequest -from utils.auth_utils import get_current_user_id +from utils.auth_utils import get_current_user_id, get_current_user_info +from utils.prompt_template_utils import get_skill_creation_simple_prompt_template +from nexent.core.agents.agent_model import ModelConfig +from agents.skill_creation_agent import create_simple_skill_from_request +from nexent.core.utils.observer import MessageObserver logger = logging.getLogger(__name__) @@ -363,6 +368,47 @@ async def list_skill_instances( raise HTTPException(status_code=500, detail="Internal server error") +# Skill creator temp file endpoints (must be before /{skill_name} route) +@router.get("/creator/cache") +async def get_skill_creator_temp_file( + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """Read the skill creator temp file (tmp.md). + + Returns the content of the tmp.md file stored in the skills root directory. + """ + try: + get_current_user_id(authorization) + + service = SkillService() + content = service.skill_manager.read_tmp_file() + + return JSONResponse(content={"content": content or ""}) + except Exception as e: + logger.error(f"Error reading skill creator temp file: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.delete("/creator/cache") +async def delete_skill_creator_temp_file( + authorization: Optional[str] = Header(None) +) -> JSONResponse: + """Delete the skill creator temp file (tmp.md). + + Removes the tmp.md file from the skills root directory. + """ + try: + get_current_user_id(authorization) + + service = SkillService() + service.skill_manager.delete_tmp_file() + + return JSONResponse(content={"success": True}) + except Exception as e: + logger.error(f"Error deleting skill creator temp file: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.get("/{skill_name}") async def get_skill(skill_name: str) -> JSONResponse: """Get a specific skill by name.""" @@ -453,88 +499,105 @@ async def delete_skill( raise HTTPException(status_code=500, detail="Internal server error") -@router.delete("/{skill_name}/files/{file_path:path}") -async def delete_skill_file( - skill_name: str, - file_path: str, - authorization: Optional[str] = Header(None) -) -> JSONResponse: - """Delete a specific file within a skill directory. +class SkillCreateSimpleRequest(BaseModel): + """Request model for interactive skill creation.""" + user_request: str - Args: - skill_name: Name of the skill - file_path: Relative path to the file within the skill directory - """ - try: - _, _ = get_current_user_id(authorization) - service = SkillService() - # Validate skill_name so it cannot be used for path traversal - if not skill_name: - raise HTTPException(status_code=400, detail="Invalid skill name") - if os.sep in skill_name or "/" in skill_name or ".." in skill_name: - raise HTTPException(status_code=400, detail="Invalid skill name") - - # Read config to get temp_filename for validation - config_content = service.get_skill_file_content(skill_name, "config.yaml") - if config_content is None: - raise HTTPException(status_code=404, detail="Config file not found") - - # Parse config to get temp_filename - import yaml - config = yaml.safe_load(config_content) - temp_filename = config.get("temp_filename", "") - - # Get the base directory for the skill - local_dir = os.path.join(service.skill_manager.local_skills_dir, skill_name) - - # Check for path traversal patterns in the raw file_path BEFORE any normalization - # This catches attempts like ../../etc/passwd or /etc/passwd - normalized_for_check = os.path.normpath(file_path) - if ".." in file_path or file_path.startswith("/") or (os.sep in file_path and file_path.startswith(os.sep)): - # Additional check: ensure the normalized path doesn't escape local_dir - abs_local_dir = os.path.abspath(local_dir) - abs_full_path = os.path.abspath(os.path.join(local_dir, normalized_for_check)) - try: - common = os.path.commonpath([abs_local_dir, abs_full_path]) - if common != abs_local_dir: - raise HTTPException(status_code=400, detail="Invalid file path: path traversal detected") - except ValueError: - raise HTTPException(status_code=400, detail="Invalid file path: path traversal detected") - - # Normalize the requested file path - use basename to strip directory components - safe_file_path = os.path.basename(os.path.normpath(file_path)) - - # Build full path and validate it stays within local_dir - full_path = os.path.normpath(os.path.join(local_dir, safe_file_path)) - abs_local_dir = os.path.abspath(local_dir) - abs_full_path = os.path.abspath(full_path) - - # Check for path traversal: abs_full_path should be within abs_local_dir - try: - common = os.path.commonpath([abs_local_dir, abs_full_path]) - if common != abs_local_dir: - raise HTTPException(status_code=400, detail="Invalid file path: path traversal detected") - except ValueError: - # Different drives on Windows - raise HTTPException(status_code=400, detail="Invalid file path: path traversal detected") +def _build_model_config_from_tenant(tenant_id: str) -> ModelConfig: + """Build ModelConfig from tenant's quick-config LLM model.""" + from utils.config_utils import tenant_config_manager, get_model_name_from_config + from consts.const import MODEL_CONFIG_MAPPING - # Validate the filename matches temp_filename - if not temp_filename or safe_file_path != temp_filename: - raise HTTPException(status_code=400, detail="Can only delete temp_filename files") + quick_config = tenant_config_manager.get_model_config( + key=MODEL_CONFIG_MAPPING["llm"], + tenant_id=tenant_id + ) + if not quick_config: + raise ValueError("No LLM model configured for tenant") - # Check if file exists - if not os.path.exists(full_path): - raise HTTPException(status_code=404, detail=f"File not found: {safe_file_path}") + return ModelConfig( + cite_name=quick_config.get("display_name", "default"), + api_key=quick_config.get("api_key", ""), + model_name=get_model_name_from_config(quick_config), + url=quick_config.get("base_url", ""), + temperature=0.1, + top_p=0.95, + ssl_verify=True, + model_factory=quick_config.get("model_factory") + ) - os.remove(full_path) - logger.info(f"Deleted skill file: {full_path}") - return JSONResponse(content={"message": f"File {safe_file_path} deleted successfully"}) - except UnauthorizedError as e: - raise HTTPException(status_code=401, detail=str(e)) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error deleting skill file {skill_name}/{file_path}: {e}") - raise HTTPException(status_code=500, detail=str(e)) +@router.post("/create-simple") +async def create_simple_skill( + request: SkillCreateSimpleRequest, + authorization: Optional[str] = Header(None) +): + """Create a simple skill interactively via LLM agent. + + Loads the skill_creation_simple prompt template, runs an internal agent + with WriteSkillFileTool and ReadSkillMdTool, extracts the block + from the final answer, and streams step progress via SSE. + + Yields SSE events: + - step_count: Current agent step number + - final_answer: Complete skill content + """ + async def generate(): + try: + _, tenant_id, language = get_current_user_info(authorization) + + template = get_skill_creation_simple_prompt_template(language) + + model_config = _build_model_config_from_tenant(tenant_id) + observer = MessageObserver(lang=language) + stop_event = threading.Event() + + # Get local_skills_dir from SkillManager + skill_service = SkillService() + local_skills_dir = skill_service.skill_manager.local_skills_dir or "" + + # Start skill creation in background thread + def run_task(): + create_simple_skill_from_request( + system_prompt=template.get("system_prompt", ""), + user_prompt=request.user_request, + model_config_list=[model_config], + observer=observer, + stop_event=stop_event, + local_skills_dir=local_skills_dir + ) + + thread = threading.Thread(target=run_task) + thread.start() + + # Poll observer for step_count messages + while thread.is_alive(): + cached = observer.get_cached_message() + for msg in cached: + if isinstance(msg, str): + try: + import json + data = json.loads(msg) + if data.get("type") == "step_count": + yield f"data: {json.dumps({'type': 'step_count', 'content': data.get('content', '')}, ensure_ascii=False)}\n\n" + except (json.JSONDecodeError, Exception): + pass + await asyncio.sleep(0.1) + + thread.join() + + # Stream final answer content from observer + final_result = observer.get_final_answer() + if final_result: + yield f"data: {json.dumps({'type': 'final_answer', 'content': final_result}, ensure_ascii=False)}\n\n" + + # Send done signal + yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n" + + except Exception as e: + logger.error(f"Error in create_simple_skill stream: {e}") + import json + yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") diff --git a/backend/prompts/skill_creation_simple_zh.yaml b/backend/prompts/skill_creation_simple_zh.yaml new file mode 100644 index 000000000..95fb0df29 --- /dev/null +++ b/backend/prompts/skill_creation_simple_zh.yaml @@ -0,0 +1,174 @@ +system_prompt: |- + 你是一个专业的技能创建助手,用于帮助用户创建简单的技能 Markdown 说明文件,内容包括:技能名称、技能描述、技能标签、技能提示词等。 + + ## 执行流程 + 要解决任务,你必须通过一系列步骤向前规划,以'思考:'、'代码:'和'观察结果:'序列的循环进行: + + 1. 思考: + - 分析当前任务状态和进展 + - 解释你的决策逻辑和预期结果 + + 2. 代码: + - 用简单的Python编写代码 + - 遵循python代码规范和python语法 + - 正确调用工具解决问题 + - 考虑到代码执行与展示用户代码的区别,使用'代码:\n```\n'开头,并以'```'表达运行代码,使用'代码:\n```\n'开头,并以'```'表达展示代码 + - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码:\n```\n'开头,并以'```'表达展示代码。 + + 3. 观察结果: + - 查看代码执行结果 + - 根据结果决定下一步行动 + + ## python代码规范 + 1. 如果认为是需要执行的代码,代码内容以'代码:\n```\n'开头,并以'```'标识符结尾。如果是不需要执行仅用于展示的代码,代码内容以'代码:\n```\n'开头,并以'```'标识符结尾,其中语言类型例如python、java、javascript等; + 2. 只使用已定义的变量,变量将在多次调用之间持续保持; + 3. 使用"print()"函数让下一次的模型调用看到对应变量信息; + 4. 正确使用工具的入参,使用关键字参数,不要用字典形式; + 5. 避免在一轮对话中进行过多的工具调用,这会导致输出格式难以预测; + 6. 只在需要时调用工具,不重复相同参数的调用; + 7. 使用变量名保存函数调用结果,在每个中间步骤中,您可以使用"print()"来保存您需要的任何重要信息。被保存的信息在代码执行之间保持。print()输出的内容应被视为字符串,不要对其进行字典相关操作如.get()、[]等,避免类型错误; + 8. 工具调用使用关键字参数,如:tool_name(param1="value1", param2="value2"); + 9. 不要放弃!你负责解决任务,而不是提供解决方向。 + + ## 分步工作流程 + + 对于每条用户消息,你必须按以下精确顺序执行: + + ### 步骤 1 — 读取现有草稿(如有)以获取上下文 + + 首先,使用以下精确代码格式尝试读取临时文件获取上下文: + + ``` + draft_content = read_skill_md("", ["tmp.md"]) + print(draft_content) + ``` + + 如果文件不存在,你会收到错误或空结果。无论文件是否存在,都继续步骤 2。 + + ### 步骤 2 — 生成或更新技能内容 + + 根据用户请求创建或更新技能内容。包含 frontmatter(name、description、tags)和主要内容部分。 + + ### 步骤 3 — 保存草稿 + + 使用以下精确代码格式保存内容: + + ``` + skill_md_content = """--- + name: my-skill-name + description: What this skill does. + tags: + - tag1 + --- + + # My Skill + + ## Instructions + + Step-by-step guidance here. + + ## Examples + + Concrete usage examples here. + + ... + + """ + + write_skill_file("", "tmp.md", skill_md_content) + print("技能内容已保存") + ``` + + ### 步骤 4 — 生成总结 + + 写入确认后,生成一段简洁的总结作为最终回答,内容包括: + - 技能名称 + - 主要功能亮点(2-3句话) + - 适用场景 + + 格式示例: + ``` + ## 已创建技能:xxx + + **功能亮点**:简要描述技能的核心能力和特点。 + + **适用场景**:什么情况下适合使用此技能。 + ``` + + ## 禁止行为清单 + + - **不要**使用 "Thought:"、"Thinking:" 或任何英文思考标签 — Agent 必须使用中文格式。 + - **不要**使用裸 `` 代码块。 + - **不要**在最终回答中提及 tmp.md、临时文件或保存路径等用户不关心的实现细节。 + + ## SKILL.md 格式 + + ```markdown + --- + name: your-skill-name + description: 简短的第三人称描述,说明此 skill 的功能及何时应使用。包含触发词。 + tags: + - tag1 + - tag2 + --- + + # 该 Skill 的名称 + + ## 使用说明 + + Agent 的分步指导。要简洁——假设 Agent 已具备相关知识。 + + ## 示例(可选) + + 具体的使用示例。 + ``` + + ## 编写描述(关键) + + `description` 字段会被注入到 Agent 的系统提示词中用于 skill 发现。 + + - **使用第三人称书写**:"处理 Excel 文件并生成报告"(而非"我可以帮助你...")。 + - **包含触发词**:特定文件类型、命令或激活此 skill 的场景。 + - **要具体**:覆盖 WHAT 和 WHEN。 + + 正面示例: + + ```yaml + description: 分析 CSV 文件并生成汇总统计。当用户要求分析数据、检查 CSV 文件或需要从表格数据中获取洞察时使用。 + ``` + + 反面示例: + + ```yaml + description: 帮用户进行数据分析。# 太模糊 + ``` + + ## 编写标签(可选) + + `tags` 字段用于对 skill 进行分类。给出 1~3 个最相关的分类作为标签。 + + ## 禁止行为 + + - 不要创建多个文件、scripts/、reference.md 或 examples.md。仅限单个文件。 + - 不要在路径中使用 Windows 风格的反斜杠。 + - 不要写模糊的描述而不包含触发词。 + +user_prompt: |- + 请帮我创建一个技能,需求如下: + + {{user_request}} + + 请按照以下步骤进行: + + 1. 首先尝试读取现有草稿获取上下文 + 2. 根据需求生成或更新技能内容 + 3. 保存草稿 + 4. 生成总结作为最终回答 + + 技能内容应该包括: + - name: 技能名称(使用英文或拼音,字母小写,单词用连字符分隔) + - description: 简短的中文描述,说明此技能的功能及何时应使用,包含触发词 + - tags: 1-3 个分类标签 + - 主要内容:包含 ## 使用说明 和可选的 ## 示例 部分 + + 保存后请简洁总结技能的亮点和适用场景。 diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index b12ba19a5..0e2de4ced 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -56,6 +56,10 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw 'cluster_summary_reduce': { LANGUAGE["ZH"]: 'backend/prompts/cluster_summary_reduce_zh.yaml', LANGUAGE["EN"]: 'backend/prompts/cluster_summary_reduce_en.yaml' + }, + 'skill_creation_simple': { + LANGUAGE["ZH"]: 'backend/prompts/skill_creation_simple_zh.yaml', + LANGUAGE["EN"]: 'backend/prompts/skill_creation_simple_en.yaml' } } @@ -146,3 +150,35 @@ def get_cluster_summary_reduce_prompt_template(language: str = LANGUAGE["ZH"]) - dict: Loaded cluster summary reduce prompt template configuration """ return get_prompt_template('cluster_summary_reduce', language) + + +def get_skill_creation_simple_prompt_template(language: str = LANGUAGE["ZH"]) -> Dict[str, str]: + """ + Get skill creation simple prompt template. + + This template is now structured YAML with system_prompt and user_prompt sections. + + Args: + language: Language code ('zh' or 'en') + + Returns: + Dict[str, str]: Template with keys 'system_prompt' and 'user_prompt' + """ + template_path_map = { + LANGUAGE["ZH"]: 'backend/prompts/skill_creation_simple_zh.yaml', + LANGUAGE["EN"]: 'backend/prompts/skill_creation_simple_en.yaml' + } + + template_path = template_path_map.get(language, template_path_map[LANGUAGE["ZH"]]) + + current_dir = os.path.dirname(os.path.abspath(__file__)) + backend_dir = os.path.dirname(current_dir) + absolute_template_path = os.path.join(backend_dir, template_path.replace('backend/', '')) + + with open(absolute_template_path, 'r', encoding='utf-8') as f: + template_data = yaml.safe_load(f) + + return { + "system_prompt": template_data.get("system_prompt", ""), + "user_prompt": template_data.get("user_prompt", "") + } diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx index 406f3777a..0cb1da94b 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import ReactMarkdown from "react-markdown"; import { Modal, Tabs, @@ -25,9 +24,7 @@ import { MessagesSquare, HardDriveUpload, } from "lucide-react"; -import { getAgentByName } from "@/services/agentConfigService"; -import { conversationService } from "@/services/conversationService"; -import { extractSkillInfo } from "@/lib/skillFileUtils"; +import { extractSkillInfo, extractSkillInfoFromContent } from "@/lib/skillFileUtils"; import { MAX_RECENT_SKILLS, THINKING_STEPS_ZH, @@ -38,12 +35,15 @@ import { fetchSkillsList, submitSkillForm, submitSkillFromFile, - processSkillStream, deleteSkillCreatorTempFile, findSkillByName, searchSkillsByName as searchSkillsByNameUtil, + createSimpleSkillStream, + fetchSkillCreatorTempFile, + clearChatAndTempFile, type SkillListItem, } from "@/services/skillService"; +import { MarkdownRenderer } from "@/components/ui/markdownRenderer"; import log from "@/lib/logger"; const { TextArea } = Input; @@ -61,7 +61,7 @@ export default function SkillBuildModal({ }: SkillBuildModalProps) { const { t } = useTranslation("common"); const [form] = Form.useForm(); - const [activeTab, setActiveTab] = useState("upload"); + const [activeTab, setActiveTab] = useState("interactive"); const [isSubmitting, setIsSubmitting] = useState(false); const [allSkills, setAllSkills] = useState([]); const [searchResults, setSearchResults] = useState([]); @@ -78,12 +78,9 @@ export default function SkillBuildModal({ const [thinkingDescription, setThinkingDescription] = useState(""); const [isThinkingVisible, setIsThinkingVisible] = useState(false); const [interactiveSkillName, setInteractiveSkillName] = useState(""); + const [hasTempDraft, setHasTempDraft] = useState(false); const chatContainerRef = useRef(null); - // skill_creator agent state (cached after first lookup) - const [skillCreatorAgentId, setSkillCreatorAgentId] = useState(null); - const skillCreatorAgentIdRef = useRef(null); - // Track if component is mounted to prevent state updates after unmount const isMountedRef = useRef(true); @@ -124,20 +121,19 @@ export default function SkillBuildModal({ useEffect(() => { if (!isOpen) { form.resetFields(); - setActiveTab("upload"); + setActiveTab("interactive"); setSelectedSkillName(""); setUploadFile(null); setSearchResults([]); setChatMessages([]); setChatInput(""); setInteractiveSkillName(""); + setHasTempDraft(false); setIsNameDropdownOpen(false); setIsTagsFocused(false); setIsCreateMode(true); setUploadExtractingName(false); setUploadExtractedSkillName(""); - setSkillCreatorAgentId(null); - skillCreatorAgentIdRef.current = null; setThinkingStep(0); setThinkingDescription(""); setIsThinkingVisible(false); @@ -152,6 +148,42 @@ export default function SkillBuildModal({ }; }, []); + // Load tmp.md when switching to interactive tab + useEffect(() => { + if (activeTab !== "interactive") return; + + let cancelled = false; + fetchSkillCreatorTempFile() + .then((content) => { + if (cancelled || !content) return; + + const skillInfo = extractSkillInfoFromContent(content); + if (skillInfo && skillInfo.name) { + form.setFieldsValue({ name: skillInfo.name }); + setInteractiveSkillName(skillInfo.name); + const matchedSkill = findSkillByName(skillInfo.name, allSkills); + setIsCreateMode(!matchedSkill); + } + if (skillInfo && skillInfo.description) { + form.setFieldsValue({ description: skillInfo.description }); + } + if (skillInfo && skillInfo.tags && skillInfo.tags.length > 0) { + form.setFieldsValue({ tags: skillInfo.tags }); + } + if (skillInfo && skillInfo.contentWithoutFrontmatter) { + form.setFieldsValue({ content: skillInfo.contentWithoutFrontmatter }); + } + setHasTempDraft(true); + }) + .catch((err) => { + log.warn("Failed to load tmp.md", err); + }); + + return () => { + cancelled = true; + }; + }, [activeTab, allSkills, form]); + // Detect create/update mode when skill name changes useEffect(() => { const nameValue = interactiveSkillName.trim(); @@ -255,8 +287,9 @@ export default function SkillBuildModal({ // Cleanup temp file when modal is closed const handleModalClose = async () => { - if (activeTab === "interactive" && chatMessages.length > 0) { + if (hasTempDraft || activeTab === "interactive") { await deleteSkillCreatorTempFile(); + setHasTempDraft(false); } onCancel(); }; @@ -305,19 +338,6 @@ export default function SkillBuildModal({ } }; - // Resolve skill_creator agent - const resolveSkillCreatorAgent = async (): Promise => { - if (skillCreatorAgentIdRef.current !== null) { - const cached = skillCreatorAgentIdRef.current; - return cached < 0 ? null : cached; - } - const result = await getAgentByName("skill_creator"); - if (!result) return null; - skillCreatorAgentIdRef.current = -result.agent_id; - setSkillCreatorAgentId(result.agent_id); - return result.agent_id; - }; - // Handle chat send for interactive creation const handleChatSend = async () => { if (!chatInput.trim() || isChatLoading) return; @@ -339,103 +359,80 @@ export default function SkillBuildModal({ setIsThinkingVisible(true); const assistantId = (Date.now() + 1).toString(); + let finalAnswerContent = ""; + setChatMessages((prev) => [ ...prev, { id: assistantId, role: "assistant", content: "", timestamp: new Date() }, ]); try { - const agentId = await resolveSkillCreatorAgent(); - if (!agentId) { - throw new Error("skill_creator agent not found"); - } - - const history = chatMessages.map((msg) => ({ - role: msg.role === "user" ? "user" : "assistant", - content: msg.content, - })); - - const reader = await conversationService.runAgent( + await createSimpleSkillStream( + { user_request: currentInput }, { - query: currentInput, - conversation_id: 0, - history, - agent_id: agentId, - is_debug: true, - }, - undefined as unknown as AbortSignal - ); + onThinkingUpdate: (step, desc) => { + setThinkingStep(step); + setThinkingDescription(desc || THINKING_STEPS_ZH.find((s) => s.step === step)?.description || ""); + }, + onThinkingVisible: (visible) => setIsThinkingVisible(visible), + onStepCount: (step) => { + setThinkingStep(step); + setThinkingDescription(THINKING_STEPS_ZH.find((s) => s.step === step)?.description || ""); + }, + onFinalAnswer: (content) => { + finalAnswerContent = content; + // Update assistant message with final answer content + if (isMountedRef.current) { + setChatMessages((prev) => + prev.map((msg) => + msg.id === assistantId ? { ...msg, content } : msg + ) + ); + } + }, + onDone: async () => { + if (!isMountedRef.current) return; + setIsThinkingVisible(false); - await processSkillStream( - reader, - (step, description) => { - setThinkingStep(step); - setThinkingDescription(description); - }, - setIsThinkingVisible, - async (finalAnswer) => { - if (!isMountedRef.current) return; - - setChatMessages((prev) => - prev.map((msg) => - msg.id === assistantId ? { ...msg, content: finalAnswer } : msg - ) - ); - - const { parseSkillDraft } = await import("@/lib/skillFileUtils"); - const skillDraft = parseSkillDraft(finalAnswer); - - if (skillDraft) { - form.setFieldValue("name", skillDraft.name); - form.setFieldValue("description", skillDraft.description); - form.setFieldValue("tags", skillDraft.tags); - form.setFieldValue("content", skillDraft.content); - setInteractiveSkillName(skillDraft.name); - const existingSkill = allSkills.find( - (s) => s.name.toLowerCase() === skillDraft.name.toLowerCase() - ); - setIsCreateMode(!existingSkill); - message.success(t("skillManagement.message.skillReadyForSave")); - } else { - // Fallback: read from temp file + // Read tmp.md and parse skill info try { - const { fetchSkillConfig, fetchSkillFileContent } = await import("@/services/agentConfigService"); - const config = await fetchSkillConfig("simple-skill-creator"); - - if (config && config.temp_filename) { - const tempFilename = config.temp_filename as string; - const tempContent = await fetchSkillFileContent("simple-skill-creator", tempFilename); + if (isMountedRef.current) { + const tempContent = await fetchSkillCreatorTempFile(); if (tempContent) { - const { extractSkillInfoFromContent } = await import("@/lib/skillFileUtils"); const skillInfo = extractSkillInfoFromContent(tempContent); if (skillInfo && skillInfo.name) { - form.setFieldValue("name", skillInfo.name); + form.setFieldsValue({ name: skillInfo.name }); setInteractiveSkillName(skillInfo.name); const existingSkill = allSkills.find( - (s) => s.name.toLowerCase() === skillInfo.name.toLowerCase() + (s) => s.name.toLowerCase() === skillInfo.name?.toLowerCase() ); setIsCreateMode(!existingSkill); } if (skillInfo && skillInfo.description) { - form.setFieldValue("description", skillInfo.description); + form.setFieldsValue({ description: skillInfo.description }); } if (skillInfo && skillInfo.tags && skillInfo.tags.length > 0) { - form.setFieldValue("tags", skillInfo.tags); + form.setFieldsValue({ tags: skillInfo.tags }); } - // Use content without frontmatter - if (skillInfo.contentWithoutFrontmatter) { - form.setFieldValue("content", skillInfo.contentWithoutFrontmatter); + if (skillInfo && skillInfo.contentWithoutFrontmatter) { + form.setFieldsValue({ content: skillInfo.contentWithoutFrontmatter }); } + message.success(t("skillManagement.message.skillReadyForSave")); + setHasTempDraft(true); } } } catch (error) { - log.warn("Failed to load temp file content:", error); + log.warn("Failed to read tmp.md content:", error); } - } - }, - "zh" + }, + onError: (errorMsg) => { + log.error("Interactive skill creation error:", errorMsg); + message.error(t("skillManagement.message.chatError")); + setChatMessages((prev) => prev.filter((m) => m.id !== assistantId)); + }, + } ); } catch (error) { log.error("Interactive skill creation error:", error); @@ -448,9 +445,11 @@ export default function SkillBuildModal({ // Handle chat clear const handleChatClear = async () => { - const { clearChatAndTempFile } = await import("@/services/skillService"); await clearChatAndTempFile(); setChatMessages([]); + setHasTempDraft(false); + form.resetFields(["name", "description", "tags", "content"]); + setInteractiveSkillName(""); }; // Scroll to bottom of chat when new messages arrive @@ -460,15 +459,6 @@ export default function SkillBuildModal({ } }, [chatMessages]); - // Import extractSkillGenerationResult - const extractSkillGenerationResult = (content: string): string => { - const skillTagIndex = content.indexOf(""); - if (skillTagIndex !== -1) { - return content.substring(skillTagIndex + 8).trim(); - } - return content; - }; - const renderInteractiveTab = () => { return (
@@ -482,7 +472,7 @@ export default function SkillBuildModal({ {t("skillManagement.tabs.interactive")} - {chatMessages.length > 0 && ( + {(hasTempDraft || chatMessages.length > 0) && (