diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index bc4031e0a..513a5a566 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -14,6 +14,7 @@ ElasticSearchService, get_vector_db_core, get_embedding_model, + get_rerank_model, ) from services.remote_mcp_service import get_remote_mcp_server_list from services.memory_config_service import build_memory_context @@ -296,8 +297,6 @@ async def create_agent_config( } system_prompt = Template(prompt_template["system_prompt"], undefined=StrictUndefined).render(render_kwargs) - _print_prompt_with_token_count(system_prompt, agent_id, "BEFORE_INJECTION") - if agent_info.get("model_id") is not None: model_info = get_model_by_model_id(agent_info.get("model_id")) model_name = model_info["display_name"] if model_info is not None else "main_model" @@ -350,11 +349,32 @@ async def create_tool_config_list(agent_id, tenant_id, user_id, version_no: int tool_config.metadata = langchain_tool break - # special logic for knowledge base search tool + # special logic for search tools that may use reranking models if tool_config.class_name == "KnowledgeBaseSearchTool": - tool_config.metadata = { + rerank = param_dict.get("rerank", False) + rerank_model_name = param_dict.get("rerank_model_name", "") + rerank_model = None + if rerank and rerank_model_name: + rerank_model = get_rerank_model( + tenant_id=tenant_id, model_name=rerank_model_name + ) + + tool_config.metadata = { "vdb_core": get_vector_db_core(), "embedding_model": get_embedding_model(tenant_id=tenant_id), + "rerank_model": rerank_model, + } + elif tool_config.class_name in ["DifySearchTool", "DataMateSearchTool"]: + rerank = param_dict.get("rerank", False) + rerank_model_name = param_dict.get("rerank_model_name", "") + rerank_model = None + if rerank and rerank_model_name: + rerank_model = get_rerank_model( + tenant_id=tenant_id, model_name=rerank_model_name + ) + + tool_config.metadata = { + "rerank_model": rerank_model, } elif tool_config.class_name == "AnalyzeTextFileTool": tool_config.metadata = { @@ -430,25 +450,9 @@ async def prepare_prompt_templates( prompt_templates = get_agent_prompt_template(is_manager, language) prompt_templates["system_prompt"] = system_prompt - # Print final prompt with all injections - _print_prompt_with_token_count(prompt_templates["system_prompt"], agent_id, "FINAL_PROMPT") - return prompt_templates -def _print_prompt_with_token_count(prompt: str, agent_id: int = None, stage: str = "PROMPT"): - """Print prompt content and estimate token count using tiktoken.""" - try: - import tiktoken - encoding = tiktoken.get_encoding("cl100k_base") - token_count = len(encoding.encode(prompt)) - logger.info(f"[Skill Debug][{stage}] Agent {agent_id} token count: {token_count}") - logger.info(f"[Skill Debug][{stage}] Agent {agent_id} prompt:\n{prompt}") - except Exception as e: - logger.warning(f"[Skill Debug][{stage}] Failed to count tokens: {e}") - logger.info(f"[Skill Debug][{stage}] Agent {agent_id} prompt:\n{prompt}") - - async def join_minio_file_description_to_query(minio_files, query): final_query = query if minio_files and isinstance(minio_files, list): 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/consts/const.py b/backend/consts/const.py index 5bfd012ff..82ead68cf 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -326,7 +326,7 @@ class VectorDatabaseType(str, Enum): MODEL_ENGINE_ENABLED = os.getenv("MODEL_ENGINE_ENABLED") # APP Version -APP_VERSION = "v1.8.1" +APP_VERSION = "v2.0.1" # Container Platform Configuration IS_DEPLOYED_BY_KUBERNETES = os.getenv("IS_DEPLOYED_BY_KUBERNETES", "false").lower() == "true" 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/services/model_health_service.py b/backend/services/model_health_service.py index 78f6413ee..9214a1ffa 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -3,6 +3,7 @@ from nexent.core import MessageObserver from nexent.core.models import OpenAIModel, OpenAIVLModel from nexent.core.models.embedding_model import JinaEmbedding, OpenAICompatibleEmbedding +from nexent.core.models.rerank_model import OpenAICompatibleRerank from services.voice_service import get_voice_service from consts.const import LOCALHOST_IP, LOCALHOST_NAME, DOCKER_INTERNAL_HOST @@ -102,7 +103,13 @@ async def _perform_connectivity_check( ssl_verify=ssl_verify ).check_connectivity() elif model_type == "rerank": - connectivity = False + rerank_model = OpenAICompatibleRerank( + model_name=model_name, + base_url=model_base_url, + api_key=model_api_key, + ssl_verify=ssl_verify, + ) + connectivity = await rerank_model.connectivity_check() elif model_type == "vlm": observer = MessageObserver() connectivity = await OpenAIVLModel( diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 8c397dc70..dbff17082 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -132,6 +132,11 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a model_dict["base_url"] = f"{model_url.rstrip('/')}/{MODEL_ENGINE_NORTH_PREFIX}/embeddings" # The embedding dimension might differ from the provided max_tokens. model_dict["max_tokens"] = await embedding_dimension_check(model_dict) + elif model["model_type"] == "rerank": + if provider == ProviderEnum.DASHSCOPE.value: + model_dict["base_url"] = f"{model_url.replace('compatible-mode/v1','api/v1').rstrip('/')}/services/rerank/text-rerank/text-rerank" + else: + model_dict["base_url"] = f"{model_url.rstrip('/')}/rerank" else: # For non-embedding models if provider == ProviderEnum.MODELENGINE.value: diff --git a/backend/services/providers/dashscope_provider.py b/backend/services/providers/dashscope_provider.py index 4ecbcbb1d..b9fb7ab7b 100644 --- a/backend/services/providers/dashscope_provider.py +++ b/backend/services/providers/dashscope_provider.py @@ -58,7 +58,7 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: "chat": [], # Maps to "llm" "vlm": [], # Maps to "vlm" "embedding": [], # Maps to "embedding" / "multi_embedding" - "reranker": [], # Maps to "reranker" + "rerank": [], # Maps to "rerank" "tts": [], # Maps to "tts" "stt": [] # Maps to "stt" } @@ -88,10 +88,10 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: categorized_models['embedding'].append(cleaned_model) continue - # 2. Reranker + # 2. Rerank if 'rerank' in m_id.lower() or '重排序' in desc: - cleaned_model.update({"model_tag": "reranker", "model_type": "reranker"}) - categorized_models['reranker'].append(cleaned_model) + cleaned_model.update({"model_tag": "rerank", "model_type": "rerank"}) + categorized_models['rerank'].append(cleaned_model) continue # 3. STT diff --git a/backend/services/providers/silicon_provider.py b/backend/services/providers/silicon_provider.py index 29de51fce..ea41cc95d 100644 --- a/backend/services/providers/silicon_provider.py +++ b/backend/services/providers/silicon_provider.py @@ -30,6 +30,8 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: silicon_url = f"{SILICON_GET_URL}?sub_type=chat" elif model_type in ("embedding", "multi_embedding"): silicon_url = f"{SILICON_GET_URL}?sub_type=embedding" + elif model_type == "rerank": + silicon_url = f"{SILICON_GET_URL}?sub_type=reranker" else: silicon_url = SILICON_GET_URL @@ -48,6 +50,10 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: for item in model_list: item["model_tag"] = "embedding" item["model_type"] = model_type + elif model_type == "rerank": + for item in model_list: + item["model_tag"] = "rerank" + item["model_type"] = model_type # Return empty list to indicate successful API call but no models if not model_list: diff --git a/backend/services/providers/tokenpony_provider.py b/backend/services/providers/tokenpony_provider.py index 42e5d178c..ab4446c1b 100644 --- a/backend/services/providers/tokenpony_provider.py +++ b/backend/services/providers/tokenpony_provider.py @@ -47,7 +47,7 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: "chat": [], # Maps to "llm" "vlm": [], # Maps to "vlm" "embedding": [], # Maps to "embedding" / "multi_embedding" - "reranker": [], # Maps to "reranker" + "rerank": [], # Maps to "rerank" "tts": [], # Maps to "tts" "stt": [] # Maps to "stt" } @@ -66,10 +66,10 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: "model_type": "", "max_tokens": DEFAULT_LLM_MAX_TOKENS } - # 1. reranker + # 1. rerank if 'rerank' in m_id: - cleaned_model.update({"model_tag": "reranker", "model_type": "reranker"}) - categorized_models['reranker'].append(cleaned_model) + cleaned_model.update({"model_tag": "rerank", "model_type": "rerank"}) + categorized_models['rerank'].append(cleaned_model) #2. embedding elif 'embedding' in m_id or m_id.startswith('bge-'): cleaned_model.update({"model_tag": "embedding", "model_type": "embedding"}) diff --git a/backend/services/tool_configuration_service.py b/backend/services/tool_configuration_service.py index a0f5b2399..9653b2e10 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -28,7 +28,7 @@ check_tool_list_initialized, ) from services.file_management_service import get_llm_model -from services.vectordatabase_service import get_embedding_model, get_vector_db_core +from services.vectordatabase_service import get_embedding_model, get_rerank_model, get_vector_db_core from database.client import minio_client from services.image_service import get_vlm_model from utils.tool_utils import get_local_tools_classes, get_local_tools_description_zh @@ -694,10 +694,32 @@ def _validate_local_tool( if tool_name == "knowledge_base_search": embedding_model = get_embedding_model(tenant_id=tenant_id) vdb_core = get_vector_db_core() + + # Get rerank configuration + rerank = instantiation_params.get("rerank", False) + rerank_model_name = instantiation_params.get("rerank_model_name", "") + rerank_model = None + if rerank and rerank_model_name: + rerank_model = get_rerank_model(tenant_id=tenant_id, model_name=rerank_model_name) + params = { **instantiation_params, 'vdb_core': vdb_core, 'embedding_model': embedding_model, + 'rerank_model': rerank_model, + } + tool_instance = tool_class(**params) + elif tool_name in ["dify_search", "datamate_search"]: + # Get rerank configuration for dify and datamate search tools + rerank = instantiation_params.get("rerank", False) + rerank_model_name = instantiation_params.get("rerank_model_name", "") + rerank_model = None + if rerank and rerank_model_name: + rerank_model = get_rerank_model(tenant_id=tenant_id, model_name=rerank_model_name) + + params = { + **instantiation_params, + 'rerank_model': rerank_model, } tool_instance = tool_class(**params) elif tool_name == "analyze_image": diff --git a/backend/services/vectordatabase_service.py b/backend/services/vectordatabase_service.py index de79c812c..cf8f7f98c 100644 --- a/backend/services/vectordatabase_service.py +++ b/backend/services/vectordatabase_service.py @@ -21,6 +21,7 @@ from fastapi import Body, Depends, Path, Query from fastapi.responses import StreamingResponse from nexent.core.models.embedding_model import OpenAICompatibleEmbedding, JinaEmbedding, BaseEmbedding +from nexent.core.models.rerank_model import OpenAICompatibleRerank, BaseRerank from nexent.vector_database.base import VectorDatabaseCore from nexent.vector_database.elasticsearch_core import ElasticSearchCore from nexent.vector_database.datamate_core import DataMateCore @@ -241,6 +242,52 @@ def get_embedding_model(tenant_id: str, model_name: Optional[str] = None): return None +def get_rerank_model(tenant_id: str, model_name: Optional[str] = None): + """ + Get the rerank model for the tenant, optionally using a specific model name. + + Args: + tenant_id: Tenant ID + model_name: Optional specific model name to use (format: "model_repo/model_name" or just "model_name") + If provided, will try to find the model in the tenant's model list. + + Returns: + Rerank model instance or None + """ + # If model_name is provided, try to find it in the tenant's models + if model_name: + try: + models = get_model_records({"model_type": "rerank"}, tenant_id) + for model in models: + model_display_name = model.get("model_repo") + "/" + model["model_name"] if model.get("model_repo") else model["model_name"] + if model_display_name == model_name: + # Found the model, create rerank model instance + return OpenAICompatibleRerank( + model_name=get_model_name_from_config(model) or "", + base_url=model.get("base_url", ""), + api_key=model.get("api_key", ""), + ssl_verify=model.get("ssl_verify", True), + ) + except Exception as e: + logger.warning(f"Failed to get rerank model by name {model_name}: {e}") + + # Fall back to default rerank model + model_config = tenant_config_manager.get_model_config( + key="RERANK_ID", tenant_id=tenant_id) + + model_type = model_config.get("model_type", "") + + if model_type == "rerank": + return OpenAICompatibleRerank( + model_name=get_model_name_from_config(model_config) or "", + base_url=model_config.get("base_url", ""), + api_key=model_config.get("api_key", ""), + ssl_verify=model_config.get("ssl_verify", True), + ) + else: + return None + + class ElasticSearchService: @staticmethod async def full_delete_knowledge_base(index_name: str, vdb_core: VectorDatabaseCore, user_id: str): diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index b12ba19a5..271c60a0c 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -26,8 +26,6 @@ def get_prompt_template(template_type: str, language: str = LANGUAGE["ZH"], **kw Returns: dict: Loaded prompt template """ - logger.info( - f"Getting prompt template for type: {template_type}, language: {language}, kwargs: {kwargs}") # Define template path mapping template_paths = { @@ -56,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' } } @@ -146,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/doc/docs/.vitepress/config.mts b/doc/docs/.vitepress/config.mts index 6855a63f7..6ee76ff5d 100644 --- a/doc/docs/.vitepress/config.mts +++ b/doc/docs/.vitepress/config.mts @@ -60,10 +60,18 @@ export default defineConfig({ text: "Installation & Deployment", link: "/en/quick-start/installation", }, + { + text: "Kubernetes Installation & Deployment", + link: "/en/quick-start/kubernetes-installation", + }, { text: "Upgrade Guide", link: "/en/quick-start/upgrade-guide", }, + { + text: "Kubernetes Upgrade Guide", + link: "/en/quick-start/kubernetes-upgrade-guide", + }, { text: "FAQ", link: "/en/quick-start/faq" }, ], }, @@ -279,10 +287,18 @@ export default defineConfig({ text: "快速开始", items: [ { text: "安装部署", link: "/zh/quick-start/installation" }, + { + text: "Kubernetes 安装与部署", + link: "/zh/quick-start/kubernetes-installation", + }, { text: "升级指导", link: "/zh/quick-start/upgrade-guide", }, + { + text: "Kubernetes 升级指南", + link: "/zh/quick-start/kubernetes-upgrade-guide", + }, { text: "常见问题", link: "/zh/quick-start/faq" }, ], }, diff --git a/doc/docs/en/quick-start/kubernetes-installation.md b/doc/docs/en/quick-start/kubernetes-installation.md new file mode 100644 index 000000000..44ca3c993 --- /dev/null +++ b/doc/docs/en/quick-start/kubernetes-installation.md @@ -0,0 +1,216 @@ +# Kubernetes Installation & Deployment + +## 🎯 Prerequisites + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| **CPU** | 4 cores | 8 cores | +| **RAM** | 16 GiB | 64 GiB | +| **Disk** | 100 GiB | 200 GiB | +| **Architecture** | x86_64 / ARM64 | x86_64 | +| **Software** | Kubernetes 1.24+, Helm 3+, kubectl configured | Kubernetes 1.28+ | + +> **💡 Note**: The recommended configuration of **8 cores and 64 GiB RAM** provides optimal performance for production workloads. + +## 🚀 Quick Start + +### 1. Prepare Kubernetes Cluster + +Ensure your Kubernetes cluster is running and kubectl is configured with cluster access: + +```bash +kubectl cluster-info +kubectl get nodes +``` + +### 2. Clone and Navigate + +```bash +git clone https://github.com/ModelEngine-Group/nexent.git +cd nexent/k8s/helm +``` + +### 3. Deployment + +Run the deployment script: + +```bash +./deploy-helm.sh apply +``` + +After executing this command, the system will prompt for configuration options: + +**Version Selection:** +- **Speed version (Lightweight & Fast Deployment, Default)**: Quick startup of core features, suitable for individual users and small teams +- **Full version (Complete Feature Edition)**: Provides enterprise-level tenant management and resource isolation features, includes Supabase authentication + +**Image Source Selection:** +- **Mainland China**: Uses optimized regional mirrors for faster image pulling +- **General**: Uses standard Docker Hub registries + +**Optional Components:** +- **Terminal Tool**: Enables openssh-server for AI agent shell command execution + +### ⚠️ Important Notes + +1️⃣ **When deploying v1.8.0 or later for the first time**, you will be prompted to set a password for the `suadmin` super administrator account during the deployment process. This account has the highest system privileges. Please enter your desired password and **save it securely** after creation - it cannot be retrieved later. + +2️⃣ Forgot to note the `suadmin` account password? Follow these steps: + +```bash +# Step 1: Delete su account record in Supabase database +kubectl exec -it -n nexent deploy/nexent-supabase-db -- psql -U postgres -c \ + "SELECT id, email FROM auth.users WHERE email='suadmin@nexent.com';" +# Get the user_id and delete +kubectl exec -it -n nexent deploy/nexent-supabase-db -- psql -U postgres -c \ + "DELETE FROM auth.identities WHERE user_id='your_user_id';" +kubectl exec -it -n nexent deploy/nexent-supabase-db -- psql -U postgres -c \ + "DELETE FROM auth.users WHERE id='your_user_id';" + +# Step 2: Delete su account record in nexent database +kubectl exec -it -n nexent deploy/nexent-postgresql -- psql -U root -d nexent -c \ + "DELETE FROM nexent.user_tenant_t WHERE user_id='your_user_id';" + +# Step 3: Re-deploy and record the su account password +./deploy-helm.sh apply +``` + +### 4. Access Your Installation + +When deployment completes successfully: + +| Service | Default Address | +|---------|-----------------| +| Web Application | http://localhost:30000 | +| SSH Terminal | localhost:30022 (if enabled) | + +Access steps: +1. Open **http://localhost:30000** in your browser +2. Log in with the super administrator account +3. Access tenant resources → Create tenant and tenant administrator +4. Log in with the tenant administrator account +5. Refer to the [User Guide](../user-guide/home-page) to develop agents + +## 🏗️ Service Architecture + +Nexent uses a microservices architecture deployed via Helm charts: + +**Application Services:** +| Service | Description | Default Port | +|---------|-------------|--------------| +| nexent-config | Configuration service | 5010 | +| nexent-runtime | Runtime service | 5010 | +| nexent-mcp | MCP container service | 5010 | +| nexent-northbound | Northbound API service | 5010 | +| nexent-web | Web frontend | 3000 | +| nexent-data-process | Data processing service | 5012 | + +**Infrastructure Services:** +| Service | Description | +|---------|-------------| +| nexent-elasticsearch | Search and indexing engine | +| nexent-postgresql | Relational database | +| nexent-redis | Caching layer | +| nexent-minio | S3-compatible object storage | + +**Supabase Services (Full Version Only):** +| Service | Description | +|---------|-------------| +| nexent-supabase-kong | API Gateway | +| nexent-supabase-auth | Authentication service | +| nexent-supabase-db | Database service | + +**Optional Services:** +| Service | Description | +|---------|-------------| +| nexent-openssh-server | SSH terminal for AI agents | + +## 🔌 Port Mapping + +| Service | Internal Port | NodePort | Description | +|---------|---------------|----------|-------------| +| Web Interface | 3000 | 30000 | Main application access | +| Northbound API | 5010 | 30013 | Northbound API service | +| SSH Server | 22 | 30022 | Terminal tool access | + +For internal service communication, services use Kubernetes internal DNS (e.g., `http://nexent-config:5010`). + +## 💾 Data Persistence + +Nexent uses PersistentVolumes for data persistence: + +| Data Type | PersistentVolume | Default Host Path | +|-----------|------------------|-------------------| +| Elasticsearch | nexent-elasticsearch-pv | `{dataDir}/elasticsearch` | +| PostgreSQL | nexent-postgresql-pv | `{dataDir}/postgresql` | +| Redis | nexent-redis-pv | `{dataDir}/redis` | +| MinIO | nexent-minio-pv | `{dataDir}/minio` | +| Supabase DB (Full) | nexent-supabase-db-pv | `{dataDir}/supabase-db` | + +Default `dataDir` is `/var/lib/nexent-data` (configurable in `values.yaml`). + +## 🔧 Deployment Commands + +```bash +# Deploy with interactive prompts +./deploy-helm.sh apply + +# Deploy with mainland China image sources +./deploy-helm.sh apply --is-mainland Y + +# Deploy full version (with Supabase) +./deploy-helm.sh apply --deployment-version full + +# Clean helm state only (fixes stuck releases) +./deploy-helm.sh clean + +# Uninstall but preserve data +./deploy-helm.sh delete + +# Complete uninstall including all data +./deploy-helm.sh delete-all +``` + +## 🔍 Troubleshooting + +### Check Pod Status + +```bash +kubectl get pods -n nexent +kubectl describe pod -n nexent +``` + +### View Logs + +```bash +kubectl logs -n nexent -l app=nexent-config +kubectl logs -n nexent -l app=nexent-web +kubectl logs -n nexent -l app=nexent-elasticsearch +``` + +### Restart Services + +```bash +kubectl rollout restart deployment/nexent-config -n nexent +kubectl rollout restart deployment/nexent-runtime -n nexent +``` + +### Re-initialize Elasticsearch + +If Elasticsearch initialization failed: + +```bash +bash init-elasticsearch.sh +``` + +### Clean Up Stale PersistentVolumes + +```bash +kubectl delete pv nexent-elasticsearch-pv nexent-postgresql-pv nexent-redis-pv nexent-minio-pv +``` + +## 💡 Need Help + +- Browse the [FAQ](./faq) for common install issues +- Drop questions in our [Discord community](https://discord.gg/tb5H3S3wyv) +- File bugs or feature ideas in [GitHub Issues](https://github.com/ModelEngine-Group/nexent/issues) diff --git a/doc/docs/en/quick-start/kubernetes-upgrade-guide.md b/doc/docs/en/quick-start/kubernetes-upgrade-guide.md new file mode 100644 index 000000000..293358d2f --- /dev/null +++ b/doc/docs/en/quick-start/kubernetes-upgrade-guide.md @@ -0,0 +1,180 @@ +# Nexent Kubernetes Upgrade Guide + +## 🚀 Upgrade Overview + +Follow these steps to upgrade Nexent on Kubernetes safely: + +1. Pull the latest code +2. Execute the Helm deployment script +3. Open the site to confirm service availability + +--- + +## 🔄 Step 1: Update Code + +Before updating, record the current deployment version and data directory information. + +- Current Deployment Version Location: `APP_VERSION` in `backend/consts/const.py` +- Data Directory Location: `global.dataDir` in `k8s/helm/nexent/values.yaml` + +**Code downloaded via git** + +Update the code using git commands: + +```bash +git pull +``` + +**Code downloaded via ZIP package or other means** + +1. Re-download the latest code from GitHub and extract it. +2. Copy the `.deploy.options` file from the `k8s/helm` directory of your previous deployment to the new code directory. (If the file doesn't exist, you can ignore this step). + +## 🔄 Step 2: Execute the Upgrade + +Navigate to the k8s/helm directory of the updated code and run the deployment script: + +```bash +cd k8s/helm +./deploy-helm.sh apply +``` + +The script will detect your previous deployment settings (version, image source, etc.) from the `.deploy.options` file. If the file is missing, you will be prompted to enter configuration details. + +> 💡 Tip +> If you need to configure voice models (STT/TTS), please edit the corresponding values in `values.yaml` or pass them via command line. + +--- + +## 🌐 Step 3: Verify the Deployment + +After deployment: + +1. Open `http://localhost:30000` in your browser. +2. Review the [User Guide](../user-guide/home-page) to validate agent functionality. + +--- + +## 🗄️ Manual Database Update + +If some SQL files fail to execute during the upgrade, or if you need to run incremental SQL scripts manually, you can perform the update using the methods below. + +### 📋 Find SQL Scripts + +SQL migration scripts are located in the repository at: + +``` +docker/sql/ +``` + +Check the [upgrade-guide](./upgrade-guide.md) or release notes to identify which SQL scripts need to be executed for your upgrade path. + +### ✅ Method A: Use a SQL Editor (recommended) + +1. Open your SQL client and create a new PostgreSQL connection. +2. Get connection settings from the running PostgreSQL pod: + + ```bash + # Get PostgreSQL pod name + kubectl get pods -n nexent -l app=nexent-postgresql + + # Port-forward to access PostgreSQL locally + kubectl port-forward svc/nexent-postgresql 5433:5432 -n nexent & + ``` + +3. Connection details: + - Host: `localhost` + - Port: `5433` (forwarded port) + - Database: `nexent` + - User: `root` + - Password: Check in `k8s/helm/nexent/charts/nexent-common/values.yaml` + +4. Test the connection. When successful, you should see tables under the `nexent` schema. +5. Execute the required SQL file(s) in version order. + +> ⚠️ Important +> - Always back up the database first, especially in production. +> - Run scripts sequentially to avoid dependency issues. + +### 🧰 Method B: Use kubectl exec (no SQL client required) + +Execute SQL scripts directly via stdin redirection: + +1. Get the PostgreSQL pod name: + + ```bash + kubectl get pods -n nexent -l app=nexent-postgresql -o jsonpath='{.items[0].metadata.name}' + ``` + +2. Execute the SQL file directly from your host machine: + + ```bash + kubectl exec -i -n nexent -- psql -U root -d nexent < ./sql/v1.1.1_1030-update.sql + ``` + + Or if you want to see the output interactively: + + ```bash + cat ./sql/v1.1.1_1030-update.sql | kubectl exec -i -n nexent -- psql -U root -d nexent + ``` + +**Example - Execute multiple SQL files:** + +```bash +# Get PostgreSQL pod name +POSTGRES_POD=$(kubectl get pods -n nexent -l app=nexent-postgresql -o jsonpath='{.items[0].metadata.name}') + +# Execute SQL files in order +kubectl exec -i $POSTGRES_POD -n nexent -- psql -U root -d nexent < ./sql/v1.8.0_xxxxx-update.sql +kubectl exec -i $POSTGRES_POD -n nexent -- psql -U root -d nexent < ./sql/v2.0.0_0314_add_context_skill_t.sql +``` + +> 💡 Tips +> - Create a backup before running migrations: + + ```bash + POSTGRES_POD=$(kubectl get pods -n nexent -l app=nexent-postgresql -o jsonpath='{.items[0].metadata.name}') + kubectl exec nexent/$POSTGRES_POD -n nexent -- pg_dump -U root nexent > backup_$(date +%F).sql + ``` + +> - For Supabase database (full version only), use `nexent-supabase-db` pod instead: + + ```bash + SUPABASE_POD=$(kubectl get pods -n nexent -l app=nexent-supabase-db -o jsonpath='{.items[0].metadata.name}') + kubectl cp docker/sql/xxx.sql nexent/$SUPABASE_POD:/tmp/update.sql + kubectl exec -it nexent/$SUPABASE_POD -n nexent -- psql -U postgres -f /tmp/update.sql + ``` + +--- + +## 🔍 Troubleshooting + +### Check Deployment Status + +```bash +kubectl get pods -n nexent +kubectl rollout status deployment/nexent-config -n nexent +``` + +### View Logs + +```bash +kubectl logs -n nexent -l app=nexent-config --tail=100 +kubectl logs -n nexent -l app=nexent-web --tail=100 +``` + +### Restart Services After Manual SQL Update(if needed) + +If you executed SQL scripts manually, restart the affected services: + +```bash +kubectl rollout restart deployment/nexent-config -n nexent +kubectl rollout restart deployment/nexent-runtime -n nexent +``` + +### Re-initialize Elasticsearch (if needed) + +```bash +cd k8s/helm +bash init-elasticsearch.sh +``` diff --git a/doc/docs/zh/opensource-memorial-wall.md b/doc/docs/zh/opensource-memorial-wall.md index 4c0ced170..e50e3cebe 100644 --- a/doc/docs/zh/opensource-memorial-wall.md +++ b/doc/docs/zh/opensource-memorial-wall.md @@ -738,4 +738,6 @@ Nexent 加油!希望能达成所愿! ::: info BigBen0724 - 2026-03-16 在体验一众AI工具后,被 Nexent 产品所吸引,希望这一智能体开发平台能赋能我的工作生活! -::: +::: + info alex07 - 2026-3-27 + 这个平台为我提供了学习智能体的途径,提升工作效能。感谢! diff --git a/doc/docs/zh/quick-start/kubernetes-installation.md b/doc/docs/zh/quick-start/kubernetes-installation.md new file mode 100644 index 000000000..be7857fb2 --- /dev/null +++ b/doc/docs/zh/quick-start/kubernetes-installation.md @@ -0,0 +1,216 @@ +# Kubernetes 安装部署 + +## 🎯 系统要求 + +| 资源 | 最低要求 | 推荐配置 | +|----------|---------|-------------| +| **CPU** | 4 核 | 8 核 | +| **内存** | 16 GiB | 64 GiB | +| **磁盘** | 100 GiB | 200 GiB | +| **架构** | x86_64 / ARM64 | +| **软件** | Kubernetes 1.24+, Helm 3+, kubectl 已配置 | Kubernetes 1.28+ | + +> **💡 注意**:推荐的 **8 核 64 GiB 内存** 配置可确保生产环境下的最佳性能。 + +## 🚀 快速开始 + +### 1. 准备 Kubernetes 集群 + +确保 Kubernetes 集群正常运行,且 kubectl 已配置好集群访问权限: + +```bash +kubectl cluster-info +kubectl get nodes +``` + +### 2. 克隆并进入目录 + +```bash +git clone https://github.com/ModelEngine-Group/nexent.git +cd nexent/k8s/helm +``` + +### 3. 部署 + +运行部署脚本: + +```bash +./deploy-helm.sh apply +``` + +执行此命令后,系统会提示您选择配置选项: + +**版本选择:** +- **Speed version(轻量快速部署,默认)**: 快速启动核心功能,适合个人用户和小团队使用 +- **Full version(完整功能版)**: 提供企业级租户管理和资源隔离等高级功能,包含 Supabase 认证服务 + +**镜像源选择:** +- **中国大陆**: 使用优化的区域镜像源,加快镜像拉取速度 +- **通用**: 使用标准 Docker Hub 镜像源 + +**可选组件:** +- **终端工具**: 启用 openssh-server 供 AI 智能体执行 shell 命令 + +### ⚠️ 重要提示 + +1️⃣ **首次部署 v1.8.0 及以上版本时**,部署过程中系统会提示您设置 `suadmin` 超级管理员账号的密码。该账号为系统最高权限账户,请输入您想要的密码并**妥善保存**——密码创建后无法再次找回。 + +2️⃣ 忘记记录 `suadmin` 账号密码?请按照以下步骤操作: + +```bash +# Step 1: 在 Supabase 数据库中删除 su 账号记录 +kubectl exec -it -n nexent deploy/nexent-supabase-db -- psql -U postgres -c \ + "SELECT id, email FROM auth.users WHERE email='suadmin@nexent.com';" +# 获取 user_id 后执行删除 +kubectl exec -it -n nexent deploy/nexent-supabase-db -- psql -U postgres -c \ + "DELETE FROM auth.identities WHERE user_id='your_user_id';" +kubectl exec -it -n nexent deploy/nexent-supabase-db -- psql -U postgres -c \ + "DELETE FROM auth.users WHERE id='your_user_id';" + +# Step 2: 在 nexent 数据库中删除 su 账号记录 +kubectl exec -it -n nexent deploy/nexent-postgresql -- psql -U root -d nexent -c \ + "DELETE FROM nexent.user_tenant_t WHERE user_id='your_user_id';" + +# Step 3: 重新部署并记录 su 账号密码 +./deploy-helm.sh apply +``` + +### 4. 访问您的安装 + +部署成功完成后: + +| 服务 | 默认地址 | +|---------|-----------------| +| Web 应用 | http://localhost:30000 | +| SSH 终端 | localhost:30022(已启用时) | + +访问步骤: +1. 在浏览器中打开 **http://localhost:30000** +2. 登录超级管理员账号 +3. 访问租户资源 → 创建租户及租户管理员 +4. 登录租户管理员账号 +5. 参考 [用户指南](../user-guide/home-page) 进行智能体的开发 + +## 🏗️ 服务架构 + +Nexent 采用微服务架构,通过 Helm Chart 进行部署: + +**应用服务:** +| 服务 | 描述 | 默认端口 | +|---------|-------------|--------------| +| nexent-config | 配置服务 | 5010 | +| nexent-runtime | 运行时服务 | 5014 | +| nexent-mcp | MCP 容器服务 | 5011 | +| nexent-northbound | 北向 API 服务 | 5013 | +| nexent-web | Web 前端 | 3000 | +| nexent-data-process | 数据处理服务 | 5012 | + +**基础设施服务:** +| 服务 | 描述 | +|---------|-------------| +| nexent-elasticsearch | 搜索引擎和索引服务 | +| nexent-postgresql | 关系型数据库 | +| nexent-redis | 缓存层 | +| nexent-minio | S3 兼容对象存储 | + +**Supabase 服务(完整版独有):** +| 服务 | 描述 | +|---------|-------------| +| nexent-supabase-kong | API 网关 | +| nexent-supabase-auth | 认证服务 | +| nexent-supabase-db | 数据库服务 | + +**可选服务:** +| 服务 | 描述 | +|---------|-------------| +| nexent-openssh-server | AI 智能体 SSH 终端 | + +## 🔌 端口映射 + +| 服务 | 内部端口 | NodePort | 描述 | +|---------|---------------|----------|-------------| +| Web 界面 | 3000 | 30000 | 主应用程序访问 | +| Northbound API | 5010 | 30013 | 北向 API 服务 | +| SSH 服务器 | 22 | 30022 | 终端工具访问 | + +内部服务通信使用 Kubernetes 内部 DNS(例如 `http://nexent-config:5010`)。 + +## 💾 数据持久化 + +Nexent 使用 PersistentVolume 进行数据持久化: + +| 数据类型 | PersistentVolume | 默认宿主机路径 | +|-----------|------------------|-------------------| +| Elasticsearch | nexent-elasticsearch-pv | `{dataDir}/elasticsearch` | +| PostgreSQL | nexent-postgresql-pv | `{dataDir}/postgresql` | +| Redis | nexent-redis-pv | `{dataDir}/redis` | +| MinIO | nexent-minio-pv | `{dataDir}/minio` | +| Supabase DB(完整版)| nexent-supabase-db-pv | `{dataDir}/supabase-db` | + +默认 `dataDir` 为 `/var/lib/nexent-data`(可在 `values.yaml` 中配置)。 + +## 🔧 部署命令 + +```bash +# 交互式部署 +./deploy-helm.sh apply + +# 使用中国大陆镜像源部署 +./deploy-helm.sh apply --is-mainland Y + +# 部署完整版本(包含 Supabase) +./deploy-helm.sh apply --deployment-version full + +# 仅清理 Helm 状态(修复卡住的发布) +./deploy-helm.sh clean + +# 卸载但保留数据 +./deploy-helm.sh delete + +# 完全卸载包括所有数据 +./deploy-helm.sh delete-all +``` + +## 🔍 故障排查 + +### 查看 Pod 状态 + +```bash +kubectl get pods -n nexent +kubectl describe pod -n nexent +``` + +### 查看日志 + +```bash +kubectl logs -n nexent -l app=nexent-config +kubectl logs -n nexent -l app=nexent-web +kubectl logs -n nexent -l app=nexent-elasticsearch +``` + +### 重启服务 + +```bash +kubectl rollout restart deployment/nexent-config -n nexent +kubectl rollout restart deployment/nexent-runtime -n nexent +``` + +### 重新初始化 Elasticsearch + +如果 Elasticsearch 初始化失败: + +```bash +bash init-elasticsearch.sh +``` + +### 清理过期的 PersistentVolume + +```bash +kubectl delete pv nexent-elasticsearch-pv nexent-postgresql-pv nexent-redis-pv nexent-minio-pv +``` + +## 💡 需要帮助 + +- 浏览 [常见问题](./faq) 了解常见安装问题 +- 在我们的 [Discord 社区](https://discord.gg/tb5H3S3wyv) 提问 +- 在 [GitHub Issues](https://github.com/ModelEngine-Group/nexent/issues) 中提交错误报告或功能建议 diff --git a/doc/docs/zh/quick-start/kubernetes-upgrade-guide.md b/doc/docs/zh/quick-start/kubernetes-upgrade-guide.md new file mode 100644 index 000000000..43f5c1d49 --- /dev/null +++ b/doc/docs/zh/quick-start/kubernetes-upgrade-guide.md @@ -0,0 +1,180 @@ +# Nexent Kubernetes 升级指导 + +## 🚀 升级流程概览 + +在 Kubernetes 上升级 Nexent 时,建议依次完成以下几个步骤: + +1. 拉取最新代码 +2. 执行 Helm 部署脚本 +3. 打开站点确认服务可用 + +--- + +## 🔄 步骤一:更新代码 + +更新之前,先记录下当前部署的版本和数据目录信息。 + +- 当前部署版本信息的位置:`backend/consts/const.py` 中的 `APP_VERSION` +- 数据目录信息的位置:`k8s/helm/nexent/values.yaml` 中的 `global.dataDir` + +**git 方式下载的代码** + +通过 git 指令更新代码: + +```bash +git pull +``` + +**zip 包等方式下载的代码** + +1. 需要去 GitHub 上重新下载一份最新代码,并解压缩。 +2. 将之前执行部署脚本目录下 `k8s/helm` 目录中的 `.deploy.options` 文件拷贝到新代码目录的 `k8s/helm` 目录中。(如果不存在该文件则忽略此步骤)。 + +## 🔄 步骤二:执行升级 + +进入更新后代码目录的 `k8s/helm` 目录,执行部署脚本: + +```bash +cd k8s/helm +./deploy-helm.sh apply +``` + +脚本会自动检测您之前的部署设置(版本、镜像源等)。如果 `.deploy.options` 文件不存在,系统会提示您输入配置信息。 + +> 💡 提示 +> - 若需配置语音模型(STT/TTS),请在对应的 `values.yaml` 中修改相关配置,或通过命令行参数传入。 + +--- + +## 🌐 步骤三:验证部署 + +部署完成后: + +1. 在浏览器打开 `http://localhost:30000` +2. 参考 [用户指南](../user-guide/home-page) 完成智能体配置与验证 + +--- + +## 🗄️ 手动更新数据库 + +升级时如果存在部分 SQL 文件执行失败,或需要手动执行增量 SQL 脚本时,可以通过以下方法进行更新。 + +### 📋 查找 SQL 脚本 + +SQL 迁移脚本位于仓库的: + +``` +docker/sql/ +``` + +请查看 [升级指南](./upgrade-guide.md) 或版本发布说明,确认需要执行哪些 SQL 脚本。 + +### ✅ 方法一:使用 SQL 编辑器(推荐) + +1. 打开 SQL 编辑器,新建 PostgreSQL 连接。 +2. 从正在运行的 PostgreSQL Pod 中获取连接信息: + + ```bash + # 获取 PostgreSQL Pod 名称 + kubectl get pods -n nexent -l app=nexent-postgresql + + # 端口转发以便本地访问 PostgreSQL + kubectl port-forward svc/nexent-postgresql 5433:5432 -n nexent & + ``` + +3. 连接信息: + - Host: `localhost` + - Port: `5433`(转发的端口) + - Database: `nexent` + - User: `root` + - Password: 可在 `k8s/helm/nexent/charts/nexent-common/values.yaml` 中查看 + +4. 填写连接信息后测试连接,确认成功后可在 `nexent` schema 中查看所有表。 +5. 按版本顺序执行所需的 SQL 文件。 + +> ⚠️ 注意事项 +> - 升级前请备份数据库,生产环境尤为重要。 +> - SQL 脚本需按时间顺序执行,避免依赖冲突。 + +### 🧰 方法二:使用 kubectl exec(无需客户端) + +通过 stdin 重定向直接在主机上执行 SQL 脚本: + +1. 获取 PostgreSQL Pod 名称: + + ```bash + kubectl get pods -n nexent -l app=nexent-postgresql -o jsonpath='{.items[0].metadata.name}' + ``` + +2. 直接从主机执行 SQL 文件: + + ```bash + kubectl exec -i -n nexent -- psql -U root -d nexent < ./sql/v1.1.1_1030-update.sql + ``` + + 或者如果想交互式查看输出: + + ```bash + cat ./sql/v1.1.1_1030-update.sql | kubectl exec -i -n nexent -- psql -U root -d nexent + ``` + +**示例 - 依次执行多个 SQL 文件:** + +```bash +# 获取 PostgreSQL Pod 名称 +POSTGRES_POD=$(kubectl get pods -n nexent -l app=nexent-postgresql -o jsonpath='{.items[0].metadata.name}') + +# 按顺序执行 SQL 文件 +kubectl exec -i $POSTGRES_POD -n nexent -- psql -U root -d nexent < ./sql/v1.8.0_xxxxx-update.sql +kubectl exec -i $POSTGRES_POD -n nexent -- psql -U root -d nexent < ./sql/v2.0.0_0314_add_context_skill_t.sql +``` + +> 💡 提示 +> - 执行前建议先备份数据库: + + ```bash + POSTGRES_POD=$(kubectl get pods -n nexent -l app=nexent-postgresql -o jsonpath='{.items[0].metadata.name}') + kubectl exec nexent/$POSTGRES_POD -n nexent -- pg_dump -U root nexent > backup_$(date +%F).sql + ``` + +> - 对于 Supabase 数据库(仅完整版本),请使用 `nexent-supabase-db` Pod: + + ```bash + SUPABASE_POD=$(kubectl get pods -n nexent -l app=nexent-supabase-db -o jsonpath='{.items[0].metadata.name}') + kubectl cp docker/sql/xxx.sql nexent/$SUPABASE_POD:/tmp/update.sql + kubectl exec -it nexent/$SUPABASE_POD -n nexent -- psql -U postgres -f /tmp/update.sql + ``` + +--- + +## 🔍 故障排查 + +### 查看部署状态 + +```bash +kubectl get pods -n nexent +kubectl rollout status deployment/nexent-config -n nexent +``` + +### 查看日志 + +```bash +kubectl logs -n nexent -l app=nexent-config --tail=100 +kubectl logs -n nexent -l app=nexent-web --tail=100 +``` + +### 手动 SQL 更新后重启服务(如需要) + +如果您手动执行了 SQL 脚本,需要重启受影响的服务: + +```bash +kubectl rollout restart deployment/nexent-config -n nexent +kubectl rollout restart deployment/nexent-runtime -n nexent +``` + +### 重新初始化 Elasticsearch(如需要) + +```bash +cd k8s/helm +bash init-elasticsearch.sh +``` diff --git a/doc/docs/zh/user-guide/agent-development.md b/doc/docs/zh/user-guide/agent-development.md index cb4b4055d..67d3c8311 100644 --- a/doc/docs/zh/user-guide/agent-development.md +++ b/doc/docs/zh/user-guide/agent-development.md @@ -130,10 +130,11 @@ - 检索的模式 `search_mode`(默认为 `hybrid`) - 目标检索的知识库列表 `index_names`,如 `["医疗", "维生素知识大全"]` - 若不输入 `index_names`,则默认检索知识库页面所选中的全部知识库 + - 是否启用重排模型(默认为 `false`),启用后配置重排模型,实现对检索结果的重排优化 6. 输入完成后点击"执行测试"开始测试,并在下方查看测试结果 - + ## 📝 描述业务逻辑 diff --git a/doc/docs/zh/user-guide/assets/agent-development/tool-test-run-1.png b/doc/docs/zh/user-guide/assets/agent-development/tool-test-run-1.png new file mode 100644 index 000000000..e0cb534f2 Binary files /dev/null and b/doc/docs/zh/user-guide/assets/agent-development/tool-test-run-1.png differ diff --git a/doc/docs/zh/user-guide/assets/model-management/select-model-4.png b/doc/docs/zh/user-guide/assets/model-management/select-model-4.png new file mode 100644 index 000000000..78ed60633 Binary files /dev/null and b/doc/docs/zh/user-guide/assets/model-management/select-model-4.png differ diff --git a/doc/docs/zh/user-guide/local-tools/search-tools.md b/doc/docs/zh/user-guide/local-tools/search-tools.md index 4b71833c3..9c0ded771 100644 --- a/doc/docs/zh/user-guide/local-tools/search-tools.md +++ b/doc/docs/zh/user-guide/local-tools/search-tools.md @@ -31,6 +31,8 @@ title: 搜索工具 - `query`:检索问题,必填。 - `search_mode`:`hybrid`(默认,混合召回)、`accurate`(文本模糊匹配)、`semantic`(向量语义)。 - `index_names`:指定要搜索的知识库名称列表(可用用户侧名称或内部索引名),可选。 + - `enable_rerank`:是否启用重排序,默认 False。开启后会对检索结果进行二次排序,提升结果相关性。 + - `rerank_model`:重排序使用的模型,默认为系统配置的 rerank 模型。`enable_rerank` 为 True 时生效。 - 返回匹配片段的标题、路径/URL、来源类型、得分等。 - 若未选择知识库,会提示"无可用知识库"。 @@ -44,6 +46,8 @@ title: 搜索工具 - `threshold`:相似度阈值,默认 0.2。 - `index_names`:指定要搜索的知识库名称列表,可选。 - `kb_page` / `kb_page_size`:分页获取 DataMate 知识库列表。 + - `enable_rerank`:是否启用重排序,默认 False。开启后会对检索结果进行二次排序,提升结果相关性。 + - `rerank_model`:重排序使用的模型,默认为系统配置的 rerank 模型。`enable_rerank` 为 True 时生效。 - 返回包含文件名、下载链接、得分等结构化结果。 ### dify_search @@ -58,6 +62,8 @@ title: 搜索工具 - **检索参数**: - `query`:检索问题,必填。 - `search_method`:搜索方法,选项:`keyword_search`、`semantic_search`、`full_text_search`、`hybrid_search`,默认 `semantic_search`。 + - `enable_rerank`:是否启用重排序,默认 False。开启后会对检索结果进行二次排序,提升结果相关性。 + - `rerank_model`:重排序使用的模型,默认为系统配置的 rerank 模型。`enable_rerank` 为 True 时生效。 - 返回匹配片段的标题、内容、得分等。 ### exa_search / tavily_search / linkup_search @@ -79,7 +85,8 @@ title: 搜索工具 1. **选择数据源**:私有资料用 `knowledge_base_search`、`datamate_search` 或 `dify_search`;实时公开信息用 Exa/Tavily/Linkup。 2. **设置检索模式/数量**:知识库可在 `search_mode` 之间切换;公网搜索可调整 `max_results` 与是否启用图片过滤。 3. **限定范围**:需要特定知识库时填写 `index_names`,避免无关结果;DataMate 可通过阈值与 top_k 控制结果精度与数量。 -4. **结果利用**:返回为 JSON,可直接用于回答、摘要或后续引用;包含 cite 索引便于引用管理。 +4. **启用重排序(可选)**:如需提升检索结果相关性,可设置 `enable_rerank: true`,并通过 `rerank_top_n` 和 `rerank_model` 调整重排序效果。 +5. **结果利用**:返回为 JSON,可直接用于回答、摘要或后续引用;包含 cite 索引便于引用管理。 ## 🛡️ 安全与最佳实践 diff --git a/doc/docs/zh/user-guide/model-management.md b/doc/docs/zh/user-guide/model-management.md index b715ebc1a..46c1b25b4 100644 --- a/doc/docs/zh/user-guide/model-management.md +++ b/doc/docs/zh/user-guide/model-management.md @@ -52,7 +52,7 @@ Nexent支持与ModelEngine平台的无缝对接 1. **添加自定义模型** - 点击"添加自定义模型"按钮,进入添加模型弹窗。 2. **选择模型类型** - - 点击模型类型下拉框,选择要添加的模型类型(大语言模型/向量化模型/视觉语言模型)。 + - 点击模型类型下拉框,选择要添加的模型类型(大语言模型/向量化模型/视觉语言模型/重排模型)。 3. **配置模型参数** - **模型名称(必填)**:输入请求体中的模型名称。 - **展示名称**:可为模型设置一个展示名称,默认与模型名称相同。 @@ -82,7 +82,7 @@ Nexent支持与ModelEngine平台的无缝对接 2. **选择模型提供商** - 点击模型提供商下拉框,选择模型提供商。 3. **选择模型类型** - - 点击模型类型下拉框,选择要添加的模型类型(大语言模型/向量化模型/视觉语言模型)。 + - 点击模型类型下拉框,选择要添加的模型类型(大语言模型/向量化模型/视觉语言模型/重排模型)。 4. **输入API Key(必填)** - 输入您的API密钥。 5. **获取模型** @@ -150,6 +150,10 @@ Nexent支持与ModelEngine平台的无缝对接 +#### 重排模型 +重排模型用于初筛后的文档进行语义匹配与评分,确保最相关的核心答案能够排在首位,以提升检索的准确性和效率。配置合适的重排模型,可以显著提升知识库的检索效果。 + +- 点击重排模型下拉框,从已添加的重排模型中选择一个。 #### 多模态模型 @@ -161,6 +165,7 @@ Nexent支持与ModelEngine平台的无缝对接 + @@ -215,6 +220,8 @@ Nexent 支持任何 **遵循OpenAI API规范** 的大语言模型供应商,包 使用与大语言模型相同的API Key,但模型URL一般会有所差异,一般以`/v1/embeddings`为结尾,同时指定向量模型名称,如硅基流动提供的**BAAI/bge-m3**。 +#### 🔃 重排模型 +使用与大语言模型相同的API Key,但模型URL一般会有所差异,一般以`/v1/rerank`为结尾。 #### 🎤 语音模型 目前仅支持火山引擎语音,且需要在`.env`中进行配置 diff --git a/docker/.env.example b/docker/.env.example index d03cf6113..b0ba46ef4 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -155,4 +155,4 @@ LLM_SLOW_REQUEST_THRESHOLD_SECONDS=5.0 LLM_SLOW_TOKEN_RATE_THRESHOLD=10.0 # Market Backend Address -MARKET_BACKEND=https://market.nexent.tech +MARKET_BACKEND=http://60.204.251.153:8010 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 8eef651ae..6db803215 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -188,7 +188,7 @@ services: - WS_BACKEND=ws://nexent-runtime:5014 - RUNTIME_HTTP_BACKEND=http://nexent-runtime:5014 - MINIO_ENDPOINT=http://nexent-minio:9000 - - MARKET_BACKEND=https://market.nexent.tech + - MARKET_BACKEND=http://60.204.251.153:8010 logging: driver: "json-file" options: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 321f29665..4addc680b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -205,7 +205,7 @@ services: - WS_BACKEND=ws://nexent-runtime:5014 - RUNTIME_HTTP_BACKEND=http://nexent-runtime:5014 - MINIO_ENDPOINT=http://nexent-minio:9000 - - MARKET_BACKEND=https://market.nexent.tech + - MARKET_BACKEND=http://60.204.251.153:8010 - MODEL_ENGINE_ENABLED=${MODEL_ENGINE_ENABLED:-false} logging: driver: "json-file" 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 === "" ? ( -