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")} >