diff --git a/backend/agents/skill_creation_agent.py b/backend/agents/skill_creation_agent.py
new file mode 100644
index 000000000..3dc0cfa80
--- /dev/null
+++ b/backend/agents/skill_creation_agent.py
@@ -0,0 +1,122 @@
+"""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]
+
+ 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=[],
+ 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/runtime_app.py b/backend/apps/runtime_app.py
index 7420a14a2..ba856b3ce 100644
--- a/backend/apps/runtime_app.py
+++ b/backend/apps/runtime_app.py
@@ -6,6 +6,7 @@
from apps.conversation_management_app import router as conversation_management_router
from apps.memory_config_app import router as memory_config_router
from apps.file_management_app import file_management_runtime_router as file_management_router
+from apps.skill_app import skill_creator_router
from middleware.exception_handler import ExceptionHandlerMiddleware
# Create logger instance
@@ -22,3 +23,4 @@
app.include_router(memory_config_router)
app.include_router(file_management_router)
app.include_router(voice_router)
+app.include_router(skill_creator_router)
diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py
index 8bf19e8b7..dd1f1d2d3 100644
--- a/backend/apps/skill_app.py
+++ b/backend/apps/skill_app.py
@@ -1,22 +1,28 @@
"""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__)
router = APIRouter(prefix="/skills", tags=["skills"])
+skill_creator_router = APIRouter(prefix="/skills", tags=["simple-skills"])
class SkillCreateRequest(BaseModel):
@@ -453,88 +459,143 @@ 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))
+@skill_creator_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 and token content via SSE.
+
+ Yields SSE events:
+ - step_count: Current agent step number
+ - skill_content: Token-level content (thinking, code, deep_thinking, tool output)
+ - final_answer: Complete skill content
+ - done: Stream completion signal
+ """
+ # Message types to stream as skill_content (token-level output)
+ STREAMABLE_CONTENT_TYPES = frozenset([
+ "model_output_thinking",
+ "model_output_code",
+ "model_output_deep_thinking",
+ "tool",
+ "execution_logs",
+ ])
+
+ async def generate():
+ import json
+ 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 and token content messages
+ while thread.is_alive():
+ cached = observer.get_cached_message()
+ for msg in cached:
+ if isinstance(msg, str):
+ try:
+ data = json.loads(msg)
+ msg_type = data.get("type", "")
+ content = data.get("content", "")
+
+ # Stream step progress
+ if msg_type == "step_count":
+ yield f"data: {json.dumps({'type': 'step_count', 'content': content}, ensure_ascii=False)}\n\n"
+ # Stream token content (thinking, code, deep_thinking, tool output)
+ elif msg_type in STREAMABLE_CONTENT_TYPES:
+ yield f"data: {json.dumps({'type': 'skill_content', 'content': content}, ensure_ascii=False)}\n\n"
+ # Stream final_answer content separately
+ elif msg_type == "final_answer":
+ yield f"data: {json.dumps({'type': 'final_answer', 'content': content}, ensure_ascii=False)}\n\n"
+ except (json.JSONDecodeError, Exception):
+ pass
+ await asyncio.sleep(0.1)
+
+ thread.join()
+
+ # Stream any remaining cached messages after thread completes
+ remaining = observer.get_cached_message()
+ for msg in remaining:
+ if isinstance(msg, str):
+ try:
+ data = json.loads(msg)
+ msg_type = data.get("type", "")
+ content = data.get("content", "")
+
+ if msg_type == "step_count":
+ yield f"data: {json.dumps({'type': 'step_count', 'content': content}, ensure_ascii=False)}\n\n"
+ elif msg_type in STREAMABLE_CONTENT_TYPES:
+ yield f"data: {json.dumps({'type': 'skill_content', 'content': content}, ensure_ascii=False)}\n\n"
+ elif msg_type == "final_answer":
+ yield f"data: {json.dumps({'type': 'final_answer', 'content': content}, ensure_ascii=False)}\n\n"
+ except (json.JSONDecodeError, Exception):
+ pass
+
+ # 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}")
+ 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..e089c2999
--- /dev/null
+++ b/backend/prompts/skill_creation_simple_zh.yaml
@@ -0,0 +1,71 @@
+system_prompt: |-
+ 你是一个专业的技能创建助手,用于帮助用户创建简单的技能 Markdown 说明文件,内容包括:技能名称、技能描述、技能标签、技能提示词等。
+
+ ## 工作流程
+
+ 根据用户请求,直接生成技能内容并输出。**不要分步骤执行**,直接整合所有内容返回。
+
+ ## 输出格式
+
+ **重要**:所有需要写入 SKILL.md 的内容必须用 `` 和 `` XML 分隔符包裹。
+
+ ### 格式示例
+
+ ```
+
+ ---
+ name: your-skill-name
+ description: 简短的第三人称描述,说明此 skill 的功能及何时应使用。包含触发词。
+ tags:
+ - tag1
+ - tag2
+ ---
+
+ # 该 Skill 的名称
+
+ ## 使用说明
+
+ Agent 的分步指导。要简洁——假设 Agent 已具备相关知识。
+
+ ## 示例(可选)
+
+ 具体的使用示例。
+
+
+ [这里是你对用户的友好说明,如技能已创建、功能亮点等]
+ ```
+
+ ## 编写描述(关键)
+
+ `description` 字段会被注入到 Agent 的系统提示词中用于 skill 发现。
+
+ - **使用第三人称书写**:"处理 Excel 文件并生成报告"(而非"我可以帮助你...")。
+ - **包含触发词**:特定文件类型、命令或激活此 skill 的场景。
+ - **要具体**:覆盖 WHAT 和 WHEN。
+
+ ## 禁止行为清单
+
+ - **不要**使用 "Thought:"、"Thinking:" 或任何英文思考标签 — Agent 必须使用中文格式。
+ - **不要**调用额外工具写入或读取技能文件,直接生成技能内容。
+ - **不要**在 XML 分隔符外包含 SKILL.md 的完整内容。
+ - **不要**创建多个文件、scripts/、reference.md 或 examples.md。仅限单个文件。
+ - **不要**在路径中使用 Windows 风格的反斜杠。
+
+user_prompt: |-
+ 请帮我创建一个技能,需求如下:
+
+ {{user_request}}
+
+ 技能内容应该包括:
+ - name: 技能名称(使用英文或拼音,字母小写,单词用连字符分隔)
+ - description: 简短的中文描述,说明此技能的功能及何时应使用,包含触发词
+ - tags: 1-3 个分类标签
+ - 主要内容:包含 ## 使用说明 和可选的 ## 示例 部分
+
+ **重要要求**:请严格按以下两个步骤进行:
+
+ **步骤 1**:生成 SKILL.md 内容并保存到文件
+
+ **步骤 2**:生成简洁的总结作为最终回答(包括技能名称、功能亮点、适用场景)
+
+ 请确保两个步骤都执行完成!
diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py
index 7e45d8eb5..271c60a0c 100644
--- a/backend/utils/prompt_template_utils.py
+++ b/backend/utils/prompt_template_utils.py
@@ -54,6 +54,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'
}
}
@@ -144,3 +148,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 46307a0d2..0a6892487 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,
@@ -13,7 +12,6 @@ import {
Select,
message,
Flex,
- Progress,
Row,
Col,
Spin,
@@ -24,10 +22,9 @@ import {
Trash2,
MessagesSquare,
HardDriveUpload,
+ Loader2,
} 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,13 @@ import {
fetchSkillsList,
submitSkillForm,
submitSkillFromFile,
- processSkillStream,
- deleteSkillCreatorTempFile,
findSkillByName,
searchSkillsByName as searchSkillsByNameUtil,
+ createSimpleSkillStream,
+ clearChatAndTempFile,
type SkillListItem,
} from "@/services/skillService";
+import { MarkdownRenderer } from "@/components/ui/markdownRenderer";
import log from "@/lib/logger";
const { TextArea } = Input;
@@ -61,14 +59,7 @@ 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 [activeTab, setActiveTab] = useState("interactive");
const [isSubmitting, setIsSubmitting] = useState(false);
const [allSkills, setAllSkills] = useState([]);
const [searchResults, setSearchResults] = useState([]);
@@ -86,13 +77,20 @@ export default function SkillBuildModal({
const [isThinkingVisible, setIsThinkingVisible] = useState(false);
const [interactiveSkillName, setInteractiveSkillName] = useState("");
const chatContainerRef = useRef(null);
+ const contentTextAreaId = useRef("skill-content-textarea-" + Date.now());
- // skill_creator agent state (cached after first lookup)
- const [skillCreatorAgentId, setSkillCreatorAgentId] = useState(null);
- const skillCreatorAgentIdRef = useRef(null);
+ // Content input streaming state
+ const [formStreamingContent, setFormStreamingContent] = useState("");
+ const [isContentStreaming, setIsContentStreaming] = useState(false);
+ const [thinkingStreamingContent, setThinkingStreamingContent] = useState("");
+ const [summaryStreamingContent, setSummaryStreamingContent] = useState("");
+ const [isSummaryVisible, setIsSummaryVisible] = useState(false);
// Track if component is mounted to prevent state updates after unmount
const isMountedRef = useRef(true);
+ const currentAssistantIdRef = useRef("");
+ // Track if streaming is complete to prevent late onFormContent callbacks from overwriting cleaned content
+ const isStreamingCompleteRef = useRef(false);
// Name input dropdown control
const [isNameDropdownOpen, setIsNameDropdownOpen] = useState(false);
@@ -128,11 +126,10 @@ export default function SkillBuildModal({
};
}, [isOpen]);
- // TODO: [FEATURE] Update setActiveTab("upload") when interactive tab is re-enabled
useEffect(() => {
if (!isOpen) {
form.resetFields();
- setActiveTab("upload");
+ setActiveTab("interactive");
setSelectedSkillName("");
setUploadFile(null);
setSearchResults([]);
@@ -144,11 +141,15 @@ export default function SkillBuildModal({
setIsCreateMode(true);
setUploadExtractingName(false);
setUploadExtractedSkillName("");
- setSkillCreatorAgentId(null);
- skillCreatorAgentIdRef.current = null;
setThinkingStep(0);
setThinkingDescription("");
setIsThinkingVisible(false);
+ setFormStreamingContent("");
+ setThinkingStreamingContent("");
+ setSummaryStreamingContent("");
+ setIsSummaryVisible(false);
+ setIsContentStreaming(false);
+ currentAssistantIdRef.current = "";
}
}, [isOpen, form]);
@@ -160,6 +161,27 @@ export default function SkillBuildModal({
};
}, []);
+ // Sync streaming content to the current assistant chat message for real-time display.
+ // Show thinking content while thinking is visible, then switch to summary.
+ useEffect(() => {
+ if (!currentAssistantIdRef.current) return;
+ const displayContent = isSummaryVisible ? summaryStreamingContent : thinkingStreamingContent;
+ if (!displayContent) return;
+ setChatMessages((prev) =>
+ prev.map((msg) =>
+ msg.id === currentAssistantIdRef.current
+ ? { ...msg, content: displayContent }
+ : msg
+ )
+ );
+ }, [thinkingStreamingContent, summaryStreamingContent, isSummaryVisible]);
+
+ // Sync formStreamingContent to the form content field for real-time display
+ useEffect(() => {
+ if (!formStreamingContent) return;
+ form.setFieldValue("content", formStreamingContent);
+ }, [formStreamingContent, form]);
+
// Detect create/update mode when skill name changes
useEffect(() => {
const nameValue = interactiveSkillName.trim();
@@ -238,7 +260,7 @@ export default function SkillBuildModal({
form.setFieldsValue({
name: skill.name,
description: skill.description || "",
- source: skill.source || "Custom",
+ source: skill.source || "自定义",
content: skill.content || "",
});
}
@@ -261,11 +283,8 @@ export default function SkillBuildModal({
}, 200);
};
- // Cleanup temp file when modal is closed
- const handleModalClose = async () => {
- if (activeTab === "interactive" && chatMessages.length > 0) {
- await deleteSkillCreatorTempFile();
- }
+ // Cleanup when modal is closed
+ const handleModalClose = () => {
onCancel();
};
@@ -313,19 +332,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;
@@ -333,6 +339,15 @@ export default function SkillBuildModal({
const currentInput = chatInput.trim();
setChatInput("");
+ // Read current form fields to provide context to the model
+ const formValues = form.getFieldsValue();
+ const formContext = [
+ formValues.name ? `当前技能名称:${formValues.name}` : "",
+ formValues.description ? `当前技能描述:${formValues.description}` : "",
+ formValues.tags?.length ? `当前标签:${formValues.tags.join(", ")}` : "",
+ formValues.content ? `当前内容:\n${formValues.content}` : "",
+ ].filter(Boolean).join("\n\n");
+
const userMessage: ChatMessage = {
id: Date.now().toString(),
role: "user",
@@ -342,123 +357,119 @@ export default function SkillBuildModal({
setChatMessages((prev) => [...prev, userMessage]);
setIsChatLoading(true);
- setThinkingStep(0);
- setThinkingDescription(THINKING_STEPS_ZH.find((s) => s.step === 0)?.description || "");
+ setThinkingStep(1);
+ setThinkingDescription(THINKING_STEPS_ZH.find((s) => s.step === 1)?.description || "生成技能内容中 ...");
setIsThinkingVisible(true);
+ // Clear content input before streaming
+ form.setFieldValue("content", "");
+ setFormStreamingContent("");
+ setThinkingStreamingContent("");
+ setSummaryStreamingContent("");
+ setIsSummaryVisible(false);
+ setIsContentStreaming(true);
+ // Reset streaming complete flag
+ isStreamingCompleteRef.current = false;
+
const assistantId = (Date.now() + 1).toString();
+
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");
- }
+ // Track current assistant message ID for streaming updates
+ currentAssistantIdRef.current = assistantId;
- const history = chatMessages.map((msg) => ({
- role: msg.role === "user" ? "user" : "assistant",
- content: msg.content,
- }));
+ try {
+ // Build user prompt with form context
+ const userPrompt = formContext
+ ? `用户需求:${currentInput}\n\n${formContext}`
+ : `用户需求:${currentInput}`;
- const reader = await conversationService.runAgent(
+ await createSimpleSkillStream(
+ { user_request: userPrompt },
{
- query: currentInput,
- conversation_id: 0,
- history,
- agent_id: agentId,
- is_debug: true,
- },
- undefined as unknown as AbortSignal
- );
-
- 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
- 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 (tempContent) {
- const { extractSkillInfoFromContent } = await import("@/lib/skillFileUtils");
- const skillInfo = extractSkillInfoFromContent(tempContent);
-
- if (skillInfo && skillInfo.name) {
- form.setFieldValue("name", skillInfo.name);
- setInteractiveSkillName(skillInfo.name);
- const existingSkill = allSkills.find(
- (s) => s.name.toLowerCase() === skillInfo.name.toLowerCase()
- );
- setIsCreateMode(!existingSkill);
- }
- if (skillInfo && skillInfo.description) {
- form.setFieldValue("description", skillInfo.description);
- }
- if (skillInfo && skillInfo.tags && skillInfo.tags.length > 0) {
- form.setFieldValue("tags", skillInfo.tags);
- }
- // Use content without frontmatter
- if (skillInfo.contentWithoutFrontmatter) {
- form.setFieldValue("content", skillInfo.contentWithoutFrontmatter);
- }
- }
+ 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 || "生成技能内容中 ...");
+ },
+ onFormContent: (content) => {
+ if (isStreamingCompleteRef.current) return;
+ setFormStreamingContent((prev) => prev + content);
+ },
+ onSummaryContent: (content) => {
+ setSummaryStreamingContent((prev) => prev + content);
+ setIsSummaryVisible(true);
+ },
+ onDone: (finalResult) => {
+ if (!isMountedRef.current) return;
+ setIsThinkingVisible(false);
+ setIsContentStreaming(false);
+ currentAssistantIdRef.current = "";
+ isStreamingCompleteRef.current = true;
+
+ const finalFormContent = finalResult.formContent;
+ if (finalFormContent) {
+ const skillInfo = extractSkillInfoFromContent(finalFormContent);
+
+ if (skillInfo && skillInfo.name) {
+ form.setFieldsValue({ name: skillInfo.name });
+ setInteractiveSkillName(skillInfo.name);
+ const existingSkill = allSkills.find(
+ (s) => s.name.toLowerCase() === skillInfo.name?.toLowerCase()
+ );
+ setIsCreateMode(!existingSkill);
+ }
+ if (skillInfo && skillInfo.description) {
+ form.setFieldsValue({ description: skillInfo.description });
+ }
+ if (skillInfo && skillInfo.tags && skillInfo.tags.length > 0) {
+ form.setFieldsValue({ tags: skillInfo.tags });
}
- } catch (error) {
- log.warn("Failed to load temp file content:", error);
+ if (skillInfo && skillInfo.contentWithoutFrontmatter) {
+ form.setFieldsValue({ content: skillInfo.contentWithoutFrontmatter });
+ setFormStreamingContent(skillInfo.contentWithoutFrontmatter);
+ }
+ message.success(t("skillManagement.message.skillReadyForSave"));
}
- }
- },
- "zh"
+ },
+ onError: (errorMsg) => {
+ log.error("Interactive skill creation error:", errorMsg);
+ message.error(t("skillManagement.message.chatError"));
+ setChatMessages((prev) => prev.filter((m) => m.id !== assistantId));
+ setIsContentStreaming(false);
+ currentAssistantIdRef.current = "";
+ },
+ }
);
} catch (error) {
log.error("Interactive skill creation error:", error);
message.error(t("skillManagement.message.chatError"));
setChatMessages((prev) => prev.filter((m) => m.id !== assistantId));
+ setIsContentStreaming(false);
} finally {
setIsChatLoading(false);
}
};
- // Handle chat clear
+ // Handle chat clear - reset all form fields
const handleChatClear = async () => {
- const { clearChatAndTempFile } = await import("@/services/skillService");
await clearChatAndTempFile();
setChatMessages([]);
+ form.resetFields(["name", "description", "source", "tags", "content"]);
+ setInteractiveSkillName("");
+ setFormStreamingContent("");
+ setThinkingStreamingContent("");
+ setSummaryStreamingContent("");
+ setIsSummaryVisible(false);
};
// Scroll to bottom of chat when new messages arrive
@@ -468,14 +479,15 @@ 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();
+ // Scroll to bottom of content textarea when streaming content updates
+ useEffect(() => {
+ if (formStreamingContent) {
+ const textarea = document.getElementById(contentTextAreaId.current);
+ if (textarea) {
+ textarea.scrollTop = textarea.scrollHeight;
+ }
}
- return content;
- };
+ }, [formStreamingContent]);
const renderInteractiveTab = () => {
return (
@@ -523,25 +535,21 @@ export default function SkillBuildModal({
: "bg-gray-100 text-gray-800"
}`}
>
- {msg.role === "assistant" && isThinkingVisible && msg.content === "" ? (
-
-
+ {msg.role === "assistant" && isThinkingVisible && !isSummaryVisible ? (
+
+
{thinkingDescription && (
-
+
{thinkingDescription}
)}
) : msg.role === "assistant" ? (
-
-
- {extractSkillGenerationResult(msg.content)}
-
+
+
) : (
{msg.content}
@@ -667,8 +675,16 @@ export default function SkillBuildModal({
label={t("skillManagement.form.content")}
>