diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index bc4031e0a..faa1aa2d8 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): @@ -527,7 +531,7 @@ async def create_agent_run_info( remote_mcp_list = await get_remote_mcp_server_list(tenant_id=tenant_id, is_need_auth=True) default_mcp_url = urljoin(LOCAL_MCP_SERVER, "sse") remote_mcp_list.append({ - "remote_mcp_server_name": "nexent", + "remote_mcp_server_name": "outer-apis", "remote_mcp_server": default_mcp_url, "status": True, "authorization_token": None 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/file_management_app.py b/backend/apps/file_management_app.py index 5b7c7bc3c..50224c952 100644 --- a/backend/apps/file_management_app.py +++ b/backend/apps/file_management_app.py @@ -2,18 +2,19 @@ import re import base64 from http import HTTPStatus -from typing import List, Optional +from typing import Annotated, List, Optional from urllib.parse import urlparse, urlunparse, unquote, quote import httpx from fastapi import APIRouter, Body, File, Form, Header, HTTPException, Path as PathParam, Query, UploadFile from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse +from starlette.background import BackgroundTask -from consts.exceptions import FileTooLargeException, NotFoundException, OfficeConversionException, UnsupportedFileTypeException +from consts.exceptions import FileTooLargeException, NotFoundException, UnsupportedFileTypeException from consts.model import ProcessParams from services.file_management_service import upload_to_minio, upload_files_impl, \ get_file_url_impl, get_file_stream_impl, delete_file_impl, list_files_impl, \ - preview_file_impl + resolve_preview_file, get_preview_stream from utils.file_management_utils import trigger_data_process logger = logging.getLogger("file_management_app") @@ -578,38 +579,20 @@ async def get_storage_file_batch_urls( @file_management_config_router.get("/preview/{object_name:path}") async def preview_file( object_name: str = PathParam(..., description="File object name to preview"), - filename: Optional[str] = Query(None, description="Original filename for display (optional)") + filename: Annotated[Optional[str], Query(description="Original filename for display (optional)")] = None, + range_header: Annotated[Optional[str], Header(alias="range")] = None, ): """ - Preview file inline in browser - + Preview file inline in browser + - **object_name**: File object name in storage - **filename**: Original filename for Content-Disposition header (optional) - - Returns file stream with Content-Disposition: inline for browser preview + + Supports HTTP Range requests (RFC 7233) for partial content delivery. + Returns 206 Partial Content when a valid Range header is present. """ try: - # Get file stream from preview service - file_stream, content_type = await preview_file_impl(object_name=object_name) - - # Use provided filename or extract from object_name - display_filename = filename - if not display_filename: - display_filename = object_name.split("/")[-1] if "/" in object_name else object_name - - # Build Content-Disposition header for inline display - content_disposition = build_content_disposition_header(display_filename, inline=True) - - return StreamingResponse( - file_stream, - media_type=content_type, - headers={ - "Content-Disposition": content_disposition, - "Cache-Control": "public, max-age=3600", - "ETag": f'"{object_name}"', - } - ) - + actual_name, content_type, total_size = await resolve_preview_file(object_name=object_name) except FileTooLargeException as e: logger.warning(f"[preview_file] File too large: object_name={object_name}, error={str(e)}") raise HTTPException( @@ -625,18 +608,130 @@ async def preview_file( except UnsupportedFileTypeException as e: logger.error(f"[preview_file] Unsupported file type: object_name={object_name}, error={str(e)}") raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, + status_code=HTTPStatus.BAD_REQUEST, detail=f"File format not supported for preview: {str(e)}" ) - except OfficeConversionException as e: - logger.error(f"[preview_file] Conversion failed: object_name={object_name}, error={str(e)}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to preview file: {str(e)}" - ) except Exception as e: logger.error(f"[preview_file] Unexpected error: object_name={object_name}, error={str(e)}") raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to preview file: {str(e)}" - ) \ No newline at end of file + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to preview file" + ) + + display_filename = filename or (object_name.split("/")[-1] if "/" in object_name else object_name) + content_disposition = build_content_disposition_header(display_filename, inline=True) + + common_headers = { + "Content-Disposition": content_disposition, + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "ETag": f'"{object_name}"', + } + + if total_size == 0: + return StreamingResponse( + iter([]), + status_code=HTTPStatus.OK, + media_type=content_type, + headers={ + **common_headers, + "Content-Length": "0", + }, + ) + + # Parse Range header + start, end = None, None + if range_header: + parsed = _parse_range_header(range_header, total_size) + if parsed is None: + return StreamingResponse( + iter([]), + status_code=HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE, + headers={"Content-Range": f"bytes */{total_size}"}, + ) + start, end = parsed + + try: + if start is not None: + # 206 Partial Content + stream = get_preview_stream(actual_name, start, end) + return StreamingResponse( + stream.iter_chunks(chunk_size=64 * 1024), + status_code=HTTPStatus.PARTIAL_CONTENT, + media_type=content_type, + background=BackgroundTask(stream.close), + headers={ + **common_headers, + "Content-Range": f"bytes {start}-{end}/{total_size}", + "Content-Length": str(end - start + 1), + }, + ) + else: + # 200 Full Content — no Range header present. + stream = get_preview_stream(actual_name) + return StreamingResponse( + stream.iter_chunks(chunk_size=64 * 1024), + status_code=HTTPStatus.OK, + media_type=content_type, + background=BackgroundTask(stream.close), + headers={ + **common_headers, + "Content-Length": str(total_size), + }, + ) + except NotFoundException as e: + logger.error(f"[preview_file] File not found when streaming: object_name={object_name}, error={str(e)}") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"File not found: {object_name}") + except Exception as e: + logger.error(f"[preview_file] Unexpected error when streaming: object_name={object_name}, error={str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to preview file") + + +def _parse_range_header(range_header: str, total_size: int) -> Optional[tuple]: + """ + Parse an HTTP Range header and return (start, end) byte offsets (both inclusive). + + Supports: + - bytes=start-end + - bytes=start- (to end of file) + - bytes=-suffix (last N bytes) + + Returns None if the range is malformed or not satisfiable. + """ + try: + if total_size <= 0: + return None + if not range_header.startswith("bytes="): + return None + range_spec = range_header[6:].strip() + if "-" not in range_spec: + return None + start_str, end_str = range_spec.split("-", 1) + start_str = start_str.strip() + end_str = end_str.strip() + + if start_str == "": + # Suffix range: bytes=-N + if not end_str: + return None + suffix = int(end_str) + start = max(0, total_size - suffix) + end = total_size - 1 + elif end_str == "": + # Open-ended range: bytes=N- + start = int(start_str) + end = total_size - 1 + else: + start = int(start_str) + end = int(end_str) + + # Clamp end to last byte (RFC 7233 §2.1 allows end to exceed file size) + end = min(end, total_size - 1) + + # Validate bounds + if start < 0 or start >= total_size or end < start: + return None + + return start, end + except (ValueError, AttributeError): + return None 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..c9e35b690 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=["nl2skill"]) class SkillCreateRequest(BaseModel): @@ -453,88 +459,147 @@ 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 + existing_skill: Optional[Dict[str, Any]] = None - 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, + existing_skill=request.existing_skill + ) + + 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/apps/tool_config_app.py b/backend/apps/tool_config_app.py index 0f85caa1b..11e76975f 100644 --- a/backend/apps/tool_config_app.py +++ b/backend/apps/tool_config_app.py @@ -1,8 +1,8 @@ import logging from http import HTTPStatus -from typing import Optional +from typing import Optional, Dict, Any -from fastapi import APIRouter, Header, HTTPException +from fastapi import APIRouter, Header, HTTPException, Body from fastapi.responses import JSONResponse from consts.exceptions import MCPConnectionError, NotFoundException @@ -14,6 +14,11 @@ list_all_tools, load_last_tool_config_impl, validate_tool_impl, + import_openapi_json, + list_outer_api_tools, + get_outer_api_tool, + delete_outer_api_tool, + _refresh_outer_api_tools_in_mcp, ) from utils.auth_utils import get_current_user_id @@ -134,3 +139,131 @@ async def validate_tool( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) ) + + +# -------------------------------------------------- +# Outer API Tools (OpenAPI to MCP Conversion) +# -------------------------------------------------- + +@router.post("/import_openapi") +async def import_openapi_api( + openapi_json: Dict[str, Any] = Body(...), + authorization: Optional[str] = Header(None) +): + """ + Import OpenAPI JSON and convert tools to MCP format. + This will sync tools with the database (update existing, create new, delete removed). + After import, refreshes the MCP server to register new tools. + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + result = import_openapi_json(openapi_json, tenant_id, user_id) + + mcp_result = _refresh_outer_api_tools_in_mcp(tenant_id) + result["mcp_refresh"] = mcp_result + + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "OpenAPI import successful", + "status": "success", + "data": result + } + ) + except Exception as e: + logger.error(f"Failed to import OpenAPI: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to import OpenAPI: {str(e)}" + ) + + +@router.get("/outer_api_tools") +async def list_outer_api_tools_api( + authorization: Optional[str] = Header(None) +): + """ + List all outer API tools for the current tenant. + """ + try: + _, tenant_id = get_current_user_id(authorization) + tools = list_outer_api_tools(tenant_id) + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "success", + "data": tools + } + ) + except Exception as e: + logger.error(f"Failed to list outer API tools: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to list outer API tools: {str(e)}" + ) + + +@router.get("/outer_api_tools/{tool_id}") +async def get_outer_api_tool_api( + tool_id: int, + authorization: Optional[str] = Header(None) +): + """ + Get a specific outer API tool by ID. + """ + try: + _, tenant_id = get_current_user_id(authorization) + tool = get_outer_api_tool(tool_id, tenant_id) + if tool is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Tool not found" + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "success", + "data": tool + } + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get outer API tool: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to get outer API tool: {str(e)}" + ) + + +@router.delete("/outer_api_tools/{tool_id}") +async def delete_outer_api_tool_api( + tool_id: int, + authorization: Optional[str] = Header(None) +): + """ + Delete an outer API tool. + """ + try: + user_id, tenant_id = get_current_user_id(authorization) + deleted = delete_outer_api_tool(tool_id, tenant_id, user_id) + if not deleted: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Tool not found" + ) + return JSONResponse( + status_code=HTTPStatus.OK, + content={ + "message": "Tool deleted successfully", + "status": "success" + } + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete outer API tool: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to delete outer API tool: {str(e)}" + ) diff --git a/backend/consts/const.py b/backend/consts/const.py index 94ddd495d..bc0fbd8e2 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -1,5 +1,6 @@ import os from enum import Enum +from pathlib import Path from dotenv import load_dotenv # Load environment variables @@ -43,6 +44,11 @@ class VectorDatabaseType(str, Enum): FILE_PREVIEW_SIZE_LIMIT = 100 * 1024 * 1024 # 100MB # Limit concurrent Office-to-PDF conversions MAX_CONCURRENT_CONVERSIONS = 5 +# LibreOffice profile directory +LIBREOFFICE_PROFILE_DIR = os.getenv( + "LIBREOFFICE_PROFILE_DIR", + str(Path.home() / ".cache" / "nexent" / "libreoffice-profile"), +) # Supported Office file MIME types OFFICE_MIME_TYPES = [ 'application/msword', # .doc @@ -205,6 +211,7 @@ class VectorDatabaseType(str, Enum): # MCP Server LOCAL_MCP_SERVER = os.getenv("NEXENT_MCP_SERVER") +MCP_MANAGEMENT_API = os.getenv("MCP_MANAGEMENT_API", "http://localhost:5015") # Invite code @@ -332,4 +339,4 @@ class VectorDatabaseType(str, Enum): # APP Version -APP_VERSION = "v2.0.0" +APP_VERSION = "v2.0.1" diff --git a/backend/database/attachment_db.py b/backend/database/attachment_db.py index 2e6249468..1faabac23 100644 --- a/backend/database/attachment_db.py +++ b/backend/database/attachment_db.py @@ -278,6 +278,38 @@ def get_file_stream(object_name: str, bucket: Optional[str] = None) -> Optional[ return None +def get_file_stream_raw(object_name: str, bucket: Optional[str] = None) -> Optional[Any]: + """ + Get raw stream object from MinIO storage without reading it into memory. + + Args: + object_name: Object name in MinIO + bucket: Bucket name, if not specified use default bucket + + Returns: + Raw boto3 Body stream on success, or None if failed + """ + success, result = minio_client.get_file_stream(object_name, bucket) + return result if success else None + + +def get_file_range(object_name: str, start: int, end: int, bucket: Optional[str] = None) -> Optional[Any]: + """ + Get a byte-range stream from MinIO storage. + + Args: + object_name: Object name in MinIO + start: Start byte offset (inclusive) + end: End byte offset (inclusive), matching HTTP Range semantics. + bucket: Bucket name, if not specified use default bucket + + Returns: + Raw boto3 Body stream on success, or None if failed + """ + success, result = minio_client.get_file_range(object_name, start, end, bucket) + return result if success else None + + def get_content_type(file_path: str) -> str: """ Get content type based on file extension diff --git a/backend/database/client.py b/backend/database/client.py index 7f54532bf..014f8439f 100644 --- a/backend/database/client.py +++ b/backend/database/client.py @@ -213,6 +213,21 @@ def get_file_stream(self, object_name: str, bucket: Optional[str] = None) -> Tup """ return self._storage_client.get_file_stream(object_name, bucket) + def get_file_range(self, object_name: str, start: int, end: int, bucket: Optional[str] = None) -> Tuple[bool, Any]: + """ + Get a byte-range slice of a file from MinIO. + + Args: + object_name: Object name + start: Start byte offset (inclusive) + end: End byte offset (inclusive), matching HTTP Range semantics + bucket: Bucket name, if not specified use default bucket + + Returns: + Tuple[bool, Any]: (True, raw_body_stream) on success, (False, error_str) on failure + """ + return self._storage_client.get_file_range(object_name, start, end, bucket) + def file_exists(self, object_name: str, bucket: Optional[str] = None) -> bool: """ Check if file exists in MinIO diff --git a/backend/database/db_models.py b/backend/database/db_models.py index a1b28334c..2747659f8 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -567,3 +567,24 @@ class SkillInstance(TableBase): tenant_id = Column(String(100), doc="Tenant ID") enabled = Column(Boolean, default=True, doc="Whether this skill is enabled for the agent") version_no = Column(Integer, default=0, primary_key=True, nullable=False, doc="Version number. 0 = draft/editing state, >=1 = published snapshot") + + +class OuterApiTool(TableBase): + """ + Outer API tools table - stores converted OpenAPI tools as MCP tools. + """ + __tablename__ = "ag_outer_api_tools" + __table_args__ = {"schema": SCHEMA} + + id = Column(BigInteger, Sequence("ag_outer_api_tools_id_seq", schema=SCHEMA), + primary_key=True, nullable=False, doc="Tool ID, unique primary key") + name = Column(String(100), nullable=False, doc="Tool name (unique identifier)") + description = Column(Text, doc="Tool description") + method = Column(String(10), doc="HTTP method: GET/POST/PUT/DELETE") + url = Column(Text, nullable=False, doc="API endpoint URL") + headers_template = Column(JSONB, doc="Headers template as JSON") + query_template = Column(JSONB, doc="Query parameters template as JSON") + body_template = Column(JSONB, doc="Request body template as JSON") + input_schema = Column(JSONB, doc="MCP input schema as JSON") + tenant_id = Column(String(100), doc="Tenant ID for multi-tenancy") + is_available = Column(Boolean, default=True, doc="Whether the tool is available") diff --git a/backend/database/outer_api_tool_db.py b/backend/database/outer_api_tool_db.py new file mode 100644 index 000000000..2913bd726 --- /dev/null +++ b/backend/database/outer_api_tool_db.py @@ -0,0 +1,295 @@ +""" +Database access layer for outer API tools (OpenAPI to MCP conversion). +""" + +import logging +from typing import List, Optional, Dict, Any + +from database.client import get_db_session, filter_property, as_dict +from database.db_models import OuterApiTool + + +logger = logging.getLogger("outer_api_tool_db") + + +def create_outer_api_tool(tool_data: Dict[str, Any], tenant_id: str, user_id: str) -> OuterApiTool: + """ + Create a new outer API tool record. + + Args: + tool_data: Dictionary containing tool information + tenant_id: Tenant ID for multi-tenancy + user_id: User ID for audit + + Returns: + Created OuterApiTool object + """ + tool_dict = tool_data.copy() + tool_dict["tenant_id"] = tenant_id + tool_dict["created_by"] = user_id + tool_dict["updated_by"] = user_id + tool_dict.setdefault("is_available", True) + + with get_db_session() as session: + new_tool = OuterApiTool(**filter_property(tool_dict, OuterApiTool)) + session.add(new_tool) + session.flush() + return as_dict(new_tool) + + +def batch_create_outer_api_tools( + tools_data: List[Dict[str, Any]], + tenant_id: str, + user_id: str +) -> List[Dict[str, Any]]: + """ + Batch create outer API tool records. + + Args: + tools_data: List of tool data dictionaries + tenant_id: Tenant ID for multi-tenancy + user_id: User ID for audit + + Returns: + List of created tool dictionaries + """ + results = [] + with get_db_session() as session: + for tool_data in tools_data: + tool_dict = tool_data.copy() + tool_dict["tenant_id"] = tenant_id + tool_dict["created_by"] = user_id + tool_dict["updated_by"] = user_id + tool_dict.setdefault("is_available", True) + + new_tool = OuterApiTool(**filter_property(tool_dict, OuterApiTool)) + session.add(new_tool) + results.append(tool_dict) + session.flush() + return results + + +def query_outer_api_tools_by_tenant(tenant_id: str) -> List[Dict[str, Any]]: + """ + Query all outer API tools for a tenant. + + Args: + tenant_id: Tenant ID + + Returns: + List of tool dictionaries + """ + with get_db_session() as session: + tools = session.query(OuterApiTool).filter( + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).all() + return [as_dict(tool) for tool in tools] + + +def query_available_outer_api_tools(tenant_id: str) -> List[Dict[str, Any]]: + """ + Query all available outer API tools for a tenant. + + Args: + tenant_id: Tenant ID + + Returns: + List of available tool dictionaries + """ + with get_db_session() as session: + tools = session.query(OuterApiTool).filter( + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y', + OuterApiTool.is_available == True + ).all() + return [as_dict(tool) for tool in tools] + + +def query_outer_api_tool_by_id(tool_id: int, tenant_id: str) -> Optional[Dict[str, Any]]: + """ + Query outer API tool by ID. + + Args: + tool_id: Tool ID + tenant_id: Tenant ID + + Returns: + Tool dictionary or None + """ + with get_db_session() as session: + tool = session.query(OuterApiTool).filter( + OuterApiTool.id == tool_id, + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).first() + return as_dict(tool) if tool else None + + +def query_outer_api_tool_by_name(name: str, tenant_id: str) -> Optional[Dict[str, Any]]: + """ + Query outer API tool by name. + + Args: + name: Tool name + tenant_id: Tenant ID + + Returns: + Tool dictionary or None + """ + with get_db_session() as session: + tool = session.query(OuterApiTool).filter( + OuterApiTool.name == name, + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).first() + return as_dict(tool) if tool else None + + +def update_outer_api_tool( + tool_id: int, + tool_data: Dict[str, Any], + tenant_id: str, + user_id: str +) -> Optional[Dict[str, Any]]: + """ + Update an outer API tool record. + + Args: + tool_id: Tool ID + tool_data: Dictionary containing updated tool information + tenant_id: Tenant ID + user_id: User ID for audit + + Returns: + Updated tool dictionary or None if not found + """ + tool_dict = tool_data.copy() + tool_dict["updated_by"] = user_id + + with get_db_session() as session: + tool = session.query(OuterApiTool).filter( + OuterApiTool.id == tool_id, + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).first() + + if not tool: + return None + + for key, value in tool_dict.items(): + if hasattr(tool, key): + setattr(tool, key, value) + + session.flush() + return as_dict(tool) + + +def delete_outer_api_tool(tool_id: int, tenant_id: str, user_id: str) -> bool: + """ + Soft delete an outer API tool record. + + Args: + tool_id: Tool ID + tenant_id: Tenant ID + user_id: User ID for audit + + Returns: + True if deleted, False if not found + """ + with get_db_session() as session: + tool = session.query(OuterApiTool).filter( + OuterApiTool.id == tool_id, + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).first() + + if not tool: + return False + + tool.delete_flag = 'Y' + tool.updated_by = user_id + return True + + +def delete_all_outer_api_tools(tenant_id: str, user_id: str) -> int: + """ + Soft delete all outer API tools for a tenant. + + Args: + tenant_id: Tenant ID + user_id: User ID for audit + + Returns: + Number of deleted tools + """ + with get_db_session() as session: + count = session.query(OuterApiTool).filter( + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).update({ + OuterApiTool.delete_flag: 'Y', + OuterApiTool.updated_by: user_id + }) + return count + + +def sync_outer_api_tools( + tools_data: List[Dict[str, Any]], + tenant_id: str, + user_id: str +) -> Dict[str, Any]: + """ + Sync outer API tools: delete old ones and create new ones. + This is used for full replacement of tools from a new OpenAPI JSON upload. + + Args: + tools_data: List of tool data dictionaries to be synced + tenant_id: Tenant ID + user_id: User ID for audit + + Returns: + Dictionary with counts of created and deleted tools + """ + with get_db_session() as session: + existing_tools = session.query(OuterApiTool).filter( + OuterApiTool.tenant_id == tenant_id, + OuterApiTool.delete_flag != 'Y' + ).all() + + existing_tool_dict = {tool.name: tool for tool in existing_tools} + existing_tool_names = set(existing_tool_dict.keys()) + new_tool_names = set(t.get("name") for t in tools_data if t.get("name")) + + to_delete_names = existing_tool_names - new_tool_names + + for tool in existing_tools: + if tool.name in to_delete_names: + tool.delete_flag = 'Y' + tool.updated_by = user_id + + for tool_data in tools_data: + tool_name = tool_data.get("name") + if tool_name in existing_tool_dict: + tool = existing_tool_dict[tool_name] + for key, value in tool_data.items(): + if hasattr(tool, key): + setattr(tool, key, value) + tool.updated_by = user_id + tool.is_available = True + else: + tool_dict = tool_data.copy() + tool_dict["tenant_id"] = tenant_id + tool_dict["created_by"] = user_id + tool_dict["updated_by"] = user_id + tool_dict.setdefault("is_available", True) + new_tool = OuterApiTool(**filter_property(tool_dict, OuterApiTool)) + session.add(new_tool) + + session.flush() + + return { + "created": len(new_tool_names - existing_tool_names), + "updated": len(existing_tool_names & new_tool_names), + "deleted": len(to_delete_names) + } diff --git a/backend/mcp_service.py b/backend/mcp_service.py index c36d476ca..445a47622 100644 --- a/backend/mcp_service.py +++ b/backend/mcp_service.py @@ -1,22 +1,464 @@ +import asyncio import logging -from utils.logging_utils import configure_logging +import re +from threading import Thread +from typing import Any, Callable, Dict, List, Optional + +import requests +import uvicorn +from fastapi import FastAPI, Header, HTTPException, Query from fastmcp import FastMCP +from fastmcp.tools.tool import ToolResult + +from database.outer_api_tool_db import query_available_outer_api_tools +from mcp.types import Tool as MCPTool from tool_collection.mcp.local_mcp_service import local_mcp_service +from utils.logging_utils import configure_logging + +configure_logging(logging.INFO) +logger = logging.getLogger("mcp_service") """ hierarchical proxy architecture: - local service layer: stable local mount service - remote proxy layer: dynamic managed remote mcp service proxy +- outer_api layer: dynamic registered outer API tools """ -configure_logging(logging.INFO) -logger = logging.getLogger("mcp_service") -# initialize main mcp service -nexent_mcp = FastMCP(name="nexent_mcp") +class CustomFunctionTool: + """ + Custom tool class that uses custom parameters schema instead of inferring from function signature. + """ + def __init__( + self, + name: str, + fn: Callable[..., Any], + description: str, + parameters: Dict[str, Any], + output_schema: Optional[Dict[str, Any]] = None, + ): + self.name = name + self.key = name + self.fn = fn + self.description = description + self.parameters = parameters + self.output_schema = output_schema + self.tags: set = set() + self.enabled: bool = True + self.annotations: Optional[Any] = None + + def to_mcp_tool(self, name: str = None, **kwargs: Any) -> Any: + """Convert to MCP tool format.""" + return MCPTool( + name=self.name, + description=self.description, + inputSchema=self.parameters, + outputSchema=self.output_schema, + ) + + async def run(self, arguments: Dict[str, Any]) -> Any: + """Run the tool with arguments.""" + try: + result = self.fn(**arguments) + if hasattr(result, '__await__'): + result = await result + return ToolResult(content=str(result)) + except Exception as e: + logger.error(f"Tool '{self.name}' execution failed: {e}") + raise + -# mount local service (stable, not affected by remote proxy) +nexent_mcp = FastMCP(name="nexent_mcp") nexent_mcp.mount(local_mcp_service.name, local_mcp_service) -if __name__ == "__main__": +_registered_outer_api_tools: Dict[str, Callable] = {} + + +# FastAPI app for management endpoints (runs alongside the MCP server) +_mcp_management_app = None + + +def get_mcp_management_app(): + """Get or create FastAPI app for MCP management endpoints.""" + global _mcp_management_app + if _mcp_management_app is None: + _mcp_management_app = FastAPI(title="Nexent MCP Management") + + @_mcp_management_app.post("/tools/outer_api/refresh") + async def refresh_outer_api_tools_endpoint( + tenant_id: str = Query(..., description="Tenant ID"), + authorization: Optional[str] = Header(None) + ): + """ + Refresh outer API tools from database to MCP server. + + This endpoint is called by other services (like nexent-config) + to notify the MCP server to reload outer API tools. + """ + try: + result = refresh_outer_api_tools(tenant_id) + return { + "status": "success", + "data": result + } + except Exception as e: + logger.error(f"Failed to refresh outer API tools: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @_mcp_management_app.get("/tools/outer_api") + async def list_outer_api_tools_endpoint( + authorization: Optional[str] = Header(None) + ): + """List all registered outer API tool names.""" + return { + "status": "success", + "data": get_registered_outer_api_tools() + } + + @_mcp_management_app.delete("/tools/outer_api/{tool_name}") + async def remove_outer_api_tool_endpoint( + tool_name: str, + authorization: Optional[str] = Header(None) + ): + """ + Remove a specific outer API tool from the MCP server. + + Args: + tool_name: Name of the tool to remove + + Returns: + Success status + """ + try: + sanitized_name = _sanitize_function_name(tool_name) + result = remove_outer_api_tool(sanitized_name) + if result: + return { + "status": "success", + "message": f"Tool '{sanitized_name}' removed" + } + else: + raise HTTPException( + status_code=404, + detail=f"Tool '{sanitized_name}' not found" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to remove outer API tool '{tool_name}': {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return _mcp_management_app + + +def _sanitize_function_name(name: str) -> str: + """Sanitize function name to be valid MCP tool identifier.""" + sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name) + sanitized = re.sub(r'^[^a-zA-Z]+', '', sanitized) + if not sanitized or sanitized[0].isdigit(): + sanitized = "tool_" + sanitized + return sanitized + + +def _build_headers(headers_template: Dict[str, Any], kwargs: Dict[str, Any]) -> Dict[str, str]: + """Build request headers from template.""" + headers = {} + for key, value in headers_template.items(): + if isinstance(value, str) and "{" in value: + try: + headers[key] = value.format(**kwargs) + except KeyError: + headers[key] = value + else: + headers[key] = value + return headers + + +def _build_url(url_template: str, kwargs: Dict[str, Any]) -> str: + """Build URL from template, replacing path parameters.""" + path_params = re.findall(r'\{(\w+)\}', url_template) + for param in path_params: + if param in kwargs: + url_template = url_template.replace(f'{{{param}}}', str(kwargs[param])) + return url_template + + +def _build_query_params(query_template: Dict[str, Any], kwargs: Dict[str, Any]) -> Dict[str, Any]: + """Build query parameters from template.""" + params = {} + for key, value in query_template.items(): + if key in kwargs: + params[key] = kwargs[key] + elif isinstance(value, dict) and "default" in value: + params[key] = value["default"] + else: + params[key] = value + return params + + +def _build_request_body(body_template: Dict[str, Any], kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Build request body from template and kwargs.""" + body = {} + for key, value in body_template.items(): + if key in kwargs: + body[key] = kwargs[key] + elif value is not None: + body[key] = value + for key, value in kwargs.items(): + if key not in body and key not in _get_non_body_keys(): + body[key] = value + return body if body else None + + +def _get_non_body_keys() -> set: + """Get keys that should not be included in body.""" + return {"url", "method", "headers", "params", "json", "data"} + + +def _register_single_outer_api_tool(api_def: Dict[str, Any]) -> bool: + """ + Register a single outer API tool to the MCP server. + + Args: + api_def: Tool definition from database + + Returns: + True if registered successfully, False otherwise + """ + try: + tool_name = _sanitize_function_name(api_def.get("name", "unnamed_tool")) + + if tool_name in _registered_outer_api_tools: + logger.warning(f"Tool '{tool_name}' already registered, skipping") + return False + + method = api_def.get("method", "GET").upper() + url_template = api_def.get("url", "") + headers_template = api_def.get("headers_template") or {} + query_template = api_def.get("query_template") or {} + body_template = api_def.get("body_template") or {} + input_schema = api_def.get("input_schema") or {} + + _registered_outer_api_tools[tool_name] = { + "api_def": api_def + } + + flat_input_schema = _build_flat_input_schema(input_schema) + + async def tool_func(**kwargs: Any) -> str: + """Execute the outer API call.""" + try: + url = _build_url(url_template, kwargs) + headers = _build_headers(headers_template, kwargs) + query_params = _build_query_params(query_template, kwargs) + body = _build_request_body(body_template, kwargs) if method in ["POST", "PUT", "PATCH"] else None + + response = requests.request( + method=method, + url=url, + headers=headers, + params=query_params, + json=body + ) + + response.raise_for_status() + return response.text + + except requests.RequestException as e: + logger.error(f"Outer API tool '{tool_name}' failed: {e}") + return f"Error: {str(e)}" + except Exception as e: + logger.error(f"Outer API tool '{tool_name}' unexpected error: {e}") + return f"Error: {str(e)}" + + logger.info(f"Flat input schema for '{tool_name}': {flat_input_schema}") + + tool = CustomFunctionTool( + name=tool_name, + fn=tool_func, + description=api_def.get("description", f"Outer API tool: {tool_name}"), + parameters=flat_input_schema, + ) + + nexent_mcp.add_tool(tool) + + logger.info(f"Registered outer API tool: {tool_name}") + return True + + except Exception as e: + logger.error(f"Failed to register outer API tool '{api_def.get('name')}': {e}", exc_info=True) + return False + + +def _build_flat_input_schema(input_schema: Dict[str, Any]) -> Dict[str, Any]: + """ + Build a flat input schema from the OpenAPI input schema. + + If the input schema has a nested structure (with a single property containing + an object schema), extract the inner properties to create a flat schema. + + Args: + input_schema: Input schema from OpenAPI + + Returns: + Flattened JSON schema for MCP tool parameters + """ + if not input_schema: + return {"type": "object", "properties": {}} + + logger.debug(f"Original input_schema: {input_schema}") + + properties = input_schema.get("properties", {}) + required = input_schema.get("required", []) or [] + + if len(properties) == 1: + single_key = list(properties.keys())[0] + single_prop = properties[single_key] + + if single_prop.get("type") == "object" and "properties" in single_prop: + logger.debug(f"Flattening nested schema with key '{single_key}'") + return { + "type": "object", + "properties": single_prop.get("properties", {}), + "required": single_prop.get("required", []) or [] + } + + result = { + "type": "object", + "properties": properties, + "required": required if required else None + } + logger.debug(f"Processed input_schema: {result}") + return result + + +def register_outer_api_tools(tenant_id: str) -> Dict[str, int]: + """ + Register all outer API tools from database to the MCP server. + + Args: + tenant_id: Tenant ID to load tools for + + Returns: + Dictionary with counts of registered tools + """ + tools = query_available_outer_api_tools(tenant_id) + + registered_count = 0 + skipped_count = 0 + + for tool in tools: + if _register_single_outer_api_tool(tool): + registered_count += 1 + else: + skipped_count += 1 + + logger.info(f"Outer API tools registration complete: {registered_count} registered, {skipped_count} skipped") + return { + "registered": registered_count, + "skipped": skipped_count, + "total": len(tools) + } + + +def refresh_outer_api_tools(tenant_id: str) -> Dict[str, int]: + """ + Refresh all outer API tools: unregister all, then re-register from database. + + Args: + tenant_id: Tenant ID to load tools for + + Returns: + Dictionary with counts of refreshed tools + """ + unregister_all_outer_api_tools() + return register_outer_api_tools(tenant_id) + + +def unregister_all_outer_api_tools() -> int: + """ + Unregister all outer API tools from the MCP server. + + Returns: + Number of tools unregistered + """ + global _registered_outer_api_tools + count = len(_registered_outer_api_tools) + _registered_outer_api_tools.clear() + logger.info(f"Unregistered {count} outer API tools") + return count + + +def unregister_outer_api_tool(tool_name: str) -> bool: + """ + Unregister a specific outer API tool from the registry. + + Args: + tool_name: Name of the tool to unregister + + Returns: + True if unregistered, False if not found + """ + sanitized_name = _sanitize_function_name(tool_name) + if sanitized_name in _registered_outer_api_tools: + del _registered_outer_api_tools[sanitized_name] + logger.info(f"Unregistered outer API tool from registry: {sanitized_name}") + return True + return False + + +def remove_outer_api_tool(tool_name: str) -> bool: + """ + Remove a specific outer API tool from both the registry and MCP server. + + Args: + tool_name: Name of the tool to remove + + Returns: + True if removed, False if not found + """ + sanitized_name = _sanitize_function_name(tool_name) + + # Remove from registry + if sanitized_name in _registered_outer_api_tools: + del _registered_outer_api_tools[sanitized_name] + + # Remove from MCP server + try: + nexent_mcp.remove_tool(sanitized_name) + logger.info(f"Removed outer API tool from MCP server: {sanitized_name}") + return True + except Exception as e: + logger.warning(f"Tool '{sanitized_name}' not found in MCP server or already removed: {e}") + # Return True if it was in registry (db cleanup happened) + return sanitized_name not in _registered_outer_api_tools + + +def get_registered_outer_api_tools() -> List[str]: + """ + Get list of registered outer API tool names. + + Returns: + List of tool names + """ + return list(_registered_outer_api_tools.keys()) + + +def run_mcp_server_with_management(): + """Run MCP server with management API.""" + app = get_mcp_management_app() + + def run_fastapi(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + uvicorn.run(app, host="0.0.0.0", port=5015, log_level="info") + + fastapi_thread = Thread(target=run_fastapi, daemon=True) + fastapi_thread.start() + nexent_mcp.run(transport="sse", host="0.0.0.0", port=5011) + + +if __name__ == "__main__": + run_mcp_server_with_management() diff --git a/backend/prompts/skill_creation_simple_zh.yaml b/backend/prompts/skill_creation_simple_zh.yaml new file mode 100644 index 000000000..4b6a74603 --- /dev/null +++ b/backend/prompts/skill_creation_simple_zh.yaml @@ -0,0 +1,106 @@ +system_prompt: |- + 你是一个专业的技能创建助手,用于帮助用户创建或修改简单的技能 Markdown 说明文件,内容包括:技能名称、技能描述、技能标签、技能提示词等。 + + {% if existing_skill %} + ## 修改存量技能模式 + + 用户正在修改存量技能,请参考以下存量技能内容,并结合用户的新需求,综合生成新的技能内容。 + + ### 存量技能信息 + + **技能名称**: {{ existing_skill.name }} + **技能描述**: {{ existing_skill.description }} + **技能标签**: {{ existing_skill.tags | join(', ') if existing_skill.tags else '无' }} + + ### 存量技能内容 + + ``` + {{ existing_skill.content }} + ``` + + ### 修改指导原则 + + 1. **保留有价值部分**:如果存量技能的功能仍然有效,保留其核心逻辑 + 2. **整合新需求**:将用户新增或修改的需求整合到技能内容中 + 3. **优化而非重建**:在现有基础上优化,而非重新创建 + + {% else %} + ## 工作流程 + + 根据用户请求,直接生成技能内容并输出。**不要分步骤执行**,直接整合所有内容返回。 + + {% endif %} + ## 输出格式 + + **重要**:所有需要写入 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: |- + {% if existing_skill %} + 请帮我修改存量技能「{{ existing_skill.name }}」,需求如下: + + {{ user_request }} + + **重要**:请参考上述存量技能内容,结合用户的新需求,综合生成新的技能内容。 + + {% else %} + 请帮我创建一个技能,需求如下: + + {{ user_request }} + + {% endif %} + + 技能内容应该包括: + - name: 技能名称(使用英文或拼音,字母小写,单词用连字符分隔) + - description: 简短的中文描述,说明此技能的功能及何时应使用,包含触发词 + - tags: 1-3 个分类标签 + - 主要内容:包含 ## 使用说明 和可选的 ## 示例 部分 + + **重要要求**:请严格按以下两个步骤进行: + + **步骤 1**:生成 SKILL.md 内容 + + **步骤 2**:生成简洁的总结作为最终回答(包括技能名称、功能亮点、适用场景) + + 请确保两个步骤都执行完成! diff --git a/backend/services/data_process_service.py b/backend/services/data_process_service.py index 8c44c15e6..2b222a584 100644 --- a/backend/services/data_process_service.py +++ b/backend/services/data_process_service.py @@ -581,7 +581,7 @@ async def convert_office_to_pdf_impl(self, object_name: str, pdf_object_name: st original_filename = os.path.basename(object_name) input_path = os.path.join(temp_dir, original_filename) with open(input_path, 'wb') as f: - while chunk := original_stream.read(8192): + while chunk := original_stream.read(1024 * 1024): f.write(chunk) # Step 2: Local conversion using LibreOffice diff --git a/backend/services/file_management_service.py b/backend/services/file_management_service.py index 39b3af858..d73c91c72 100644 --- a/backend/services/file_management_service.py +++ b/backend/services/file_management_service.py @@ -23,8 +23,10 @@ delete_file, file_exists, get_content_type, + get_file_range, get_file_size_from_minio, get_file_stream, + get_file_stream_raw, get_file_url, list_files, upload_fileobj, @@ -215,16 +217,19 @@ def get_llm_model(tenant_id: str): return long_text_to_text_model -async def preview_file_impl(object_name: str) -> Tuple[BytesIO, str]: +async def resolve_preview_file(object_name: str) -> Tuple[str, str, int]: """ - Preview a file by returning its contents as a stream. + Resolve the actual object name, content type, and total size for preview. Args: object_name: File object name in storage Returns: - Tuple[BytesIO, str]: (file_stream, content_type) + Tuple[str, str, int]: (actual_object_name, content_type, total_size) """ + if not file_exists(object_name): + raise NotFoundException(f"File not found: {object_name}") + file_size = get_file_size_from_minio(object_name) if file_size > FILE_PREVIEW_SIZE_LIMIT: raise FileTooLargeException( @@ -235,10 +240,7 @@ async def preview_file_impl(object_name: str) -> Tuple[BytesIO, str]: # PDF, images, and text files - return directly if content_type == 'application/pdf' or content_type.startswith('image/') or content_type in ['text/plain', 'text/csv', 'text/markdown']: - file_stream = get_file_stream(object_name) - if file_stream is None: - raise NotFoundException("File not found or failed to read from storage") - return file_stream, content_type + return object_name, content_type, file_size # Office documents - convert to PDF with caching elif content_type in OFFICE_MIME_TYPES: @@ -247,42 +249,72 @@ async def preview_file_impl(object_name: str) -> Tuple[BytesIO, str]: pdf_object_name = f"preview/converted/{name_without_ext}_{hash_suffix}.pdf" temp_pdf_object_name = f"preview/converting/{name_without_ext}_{hash_suffix}.pdf.tmp" - # Fast path: return from cache without acquiring any lock - cached_stream = _get_cached_pdf_stream(pdf_object_name) - if cached_stream is not None: - return cached_stream, 'application/pdf' + # Trigger conversion if cache is missing or corrupted + if not _is_pdf_cache_valid(pdf_object_name): + await _convert_office_to_cached_pdf(object_name, pdf_object_name, temp_pdf_object_name) - # Slow path: convert with locking - file_stream = await _convert_office_to_cached_pdf(object_name, pdf_object_name, temp_pdf_object_name) - return file_stream, 'application/pdf' + pdf_size = get_file_size_from_minio(pdf_object_name) + return pdf_object_name, 'application/pdf', pdf_size # Unsupported file type else: raise UnsupportedFileTypeException(f"Unsupported file type for preview: {content_type}") -def _get_cached_pdf_stream(pdf_object_name: str) -> Optional[BytesIO]: +def get_preview_stream(actual_object_name: str, start: Optional[int] = None, end: Optional[int] = None): + """ + Fetch a preview stream for the given object, optionally limited to a byte range. + + Args: + actual_object_name: Resolved object name (after Office conversion if needed) + start: Start byte offset (inclusive). Must be provided together with end. + end: End byte offset (inclusive), matching HTTP Range semantics. + + Returns: + Raw boto3 Body stream """ - Return the cached PDF stream if available, or None if missing or corrupted. + if (start is None) != (end is None): + raise ValueError("start and end must be provided together") + + if start is None: + stream = get_file_stream_raw(actual_object_name) + else: + stream = get_file_range(actual_object_name, start, end) + + if stream is None: + raise NotFoundException("File not found or failed to read from storage") + return stream + - If the file exists but cannot be read, the corrupted entry is deleted so - a subsequent call will trigger a fresh conversion. +def _is_pdf_cache_valid(pdf_object_name: str) -> bool: """ - if file_exists(pdf_object_name): - file_stream = get_file_stream(pdf_object_name) - if file_stream is None: - logger.warning(f"Corrupted cache detected (cannot read), deleting: {pdf_object_name}") - delete_file(pdf_object_name) - return None - return file_stream - return None + Check whether a cached PDF exists and is readable. + """ + if not file_exists(pdf_object_name): + return False + + # Verify the cached file is readable by fetching a small range + stream = get_file_range(pdf_object_name, 0, 0) + if stream is None: + logger.warning(f"Corrupted cache detected (cannot read), deleting: {pdf_object_name}") + delete_file(pdf_object_name) + return False + + close_fn = getattr(stream, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception as e: + logger.warning(f"Failed to close cache probe stream for {pdf_object_name}: {str(e)}") + + return True async def _convert_office_to_cached_pdf( object_name: str, pdf_object_name: str, temp_pdf_object_name: str, -) -> BytesIO: +) -> None: """ Convert an Office document to PDF and store the result in MinIO. @@ -290,9 +322,6 @@ async def _convert_office_to_cached_pdf( object_name: Source Office file path in MinIO pdf_object_name: Final cached PDF path in MinIO temp_pdf_object_name: Temporary PDF path used during conversion - - Returns: - BytesIO stream of the converted PDF """ # Get or create a lock for this specific file to prevent duplicate conversions async with _conversion_locks_guard: @@ -303,9 +332,8 @@ async def _convert_office_to_cached_pdf( try: async with file_lock: # Double-check: another request may have completed the conversion while we waited - cached_stream = _get_cached_pdf_stream(pdf_object_name) - if cached_stream is not None: - return cached_stream + if _is_pdf_cache_valid(pdf_object_name): + return # Conversion semaphore is enforced inside the data-process service try: @@ -319,27 +347,37 @@ async def _convert_office_to_cached_pdf( }, ) if response.status_code != 200: - raise Exception( - f"data-process conversion returned {response.status_code}: {response.text}" + logger.error( + "Office conversion failed with non-200 response: object=%s, status=%s, body=%s", + object_name, + response.status_code, + response.text, + ) + raise RuntimeError( + f"Conversion service returned status {response.status_code}" ) # Atomic move from temp to final location, then clean up temp copy_result = copy_file(source_object=temp_pdf_object_name, dest_object=pdf_object_name) if not copy_result.get('success'): - raise Exception(f"Failed to finalize PDF cache: {copy_result.get('error', 'Unknown error')}") + logger.error( + "Failed to finalize converted PDF cache: object=%s, temp=%s, dest=%s, error=%s", + object_name, + temp_pdf_object_name, + pdf_object_name, + copy_result.get('error', 'Unknown error'), + ) + raise RuntimeError("Failed to finalize converted PDF cache") delete_file(temp_pdf_object_name) except Exception as e: if file_exists(temp_pdf_object_name): delete_file(temp_pdf_object_name) logger.error(f"Office conversion failed: {str(e)}") - raise OfficeConversionException(f"Failed to convert Office document to PDF: {str(e)}") from e + if isinstance(e, OfficeConversionException): + raise + raise OfficeConversionException("Office file conversion failed") from e finally: # Clean up the file lock (prevents memory leak for many unique files) async with _conversion_locks_guard: _conversion_locks.pop(object_name, None) - - file_stream = get_file_stream(pdf_object_name) - if file_stream is None: - raise NotFoundException("Converted PDF not found or failed to read from storage") - return file_stream 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..d7240db26 100644 --- a/backend/services/tool_configuration_service.py +++ b/backend/services/tool_configuration_service.py @@ -2,35 +2,47 @@ import inspect import json import logging +import time from typing import Any, List, Optional, Dict from urllib.parse import urljoin -from pydantic_core import PydanticUndefined -from fastmcp import Client -from fastmcp.client.transports import StreamableHttpTransport, SSETransport import jsonref -from mcpadapt.smolagents_adapter import _sanitize_function_name +import requests +from fastmcp import Client +from fastmcp.client.transports import SSETransport, StreamableHttpTransport +from pydantic_core import PydanticUndefined -from consts.const import LOCAL_MCP_SERVER, DATA_PROCESS_SERVICE -from consts.exceptions import MCPConnectionError, ToolExecutionException, NotFoundException +from consts.const import DATA_PROCESS_SERVICE, LOCAL_MCP_SERVER, MCP_MANAGEMENT_API +from consts.exceptions import MCPConnectionError, NotFoundException, ToolExecutionException from consts.model import ToolInstanceInfoRequest, ToolInfo, ToolSourceEnum, ToolValidateRequest +from database.client import minio_client +from database.outer_api_tool_db import ( + delete_outer_api_tool as db_delete_outer_api_tool, + query_outer_api_tool_by_id, + query_outer_api_tools_by_tenant, + query_available_outer_api_tools, + sync_outer_api_tools, +) from database.remote_mcp_db import ( + get_mcp_authorization_token_by_name_and_url, get_mcp_records_by_tenant, get_mcp_server_by_name_and_tenant, - get_mcp_authorization_token_by_name_and_url, ) from database.tool_db import ( + check_tool_list_initialized, create_or_update_tool_by_tool_info, query_all_tools, query_tool_instances_by_id, - update_tool_table_from_scan_tool_list, search_last_tool_instance_by_tool_id, - check_tool_list_initialized, + update_tool_table_from_scan_tool_list, ) +from mcpadapt.smolagents_adapter import _sanitize_function_name 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 services.vectordatabase_service import get_embedding_model, get_vector_db_core +from utils.langchain_utils import discover_langchain_modules from utils.tool_utils import get_local_tools_classes, get_local_tools_description_zh logger = logging.getLogger("tool_configuration_service") @@ -107,13 +119,13 @@ def get_local_tools() -> List[ToolInfo]: for tool_class in tools_classes: # Get class-level init_param_descriptions for fallback init_param_descriptions = getattr(tool_class, 'init_param_descriptions', {}) - + init_params_list = [] sig = inspect.signature(tool_class.__init__) for param_name, param in sig.parameters.items(): if param_name == "self": continue - + # Check if parameter has a default value and if it should be excluded if param.default != inspect.Parameter.empty: if hasattr(param.default, 'exclude') and param.default.exclude: @@ -121,10 +133,10 @@ def get_local_tools() -> List[ToolInfo]: # Get description in both languages param_description = param.default.description if hasattr(param.default, 'description') else "" - + # First try to get from param.default.description_zh (FieldInfo) param_description_zh = param.default.description_zh if hasattr(param.default, 'description_zh') else None - + # Fallback to init_param_descriptions if not found if param_description_zh is None and param_name in init_param_descriptions: param_description_zh = init_param_descriptions[param_name].get('description_zh') @@ -225,8 +237,6 @@ def get_langchain_tools() -> List[ToolInfo]: LangChain tools (based on presence of `name` & `description`). Any valid tool is converted to ToolInfo with source = "langchain". """ - from utils.langchain_utils import discover_langchain_modules - tools_info: List[ToolInfo] = [] # Discover all objects that look like LangChain tools discovered_tools = discover_langchain_modules() @@ -266,7 +276,7 @@ async def get_all_mcp_tools(tenant_id: str) -> List[ToolInfo]: default_mcp_url = urljoin(LOCAL_MCP_SERVER, "sse") tools_info.extend(await get_tool_from_remote_mcp_server( - mcp_server_name="nexent", + mcp_server_name="outer-apis", remote_mcp_server=default_mcp_url, tenant_id=None )) @@ -417,7 +427,8 @@ async def init_tool_list_for_tenant(tenant_id: str, user_id: str): async def update_tool_list(tenant_id: str, user_id: str): """ - Scan and gather all available tools from both local and MCP sources + Scan and gather all available tools from local, MCP, and outer API sources. + Also refreshes dynamic outer API tools in MCP server. Args: tenant_id: Tenant ID for MCP tools (required for MCP tools) @@ -427,9 +438,10 @@ async def update_tool_list(tenant_id: str, user_id: str): List of ToolInfo objects containing tool metadata """ local_tools = get_local_tools() - # Discover LangChain tools (decorated functions) and include them in the langchain_tools = get_langchain_tools() + _refresh_outer_api_tools_in_mcp(tenant_id) + try: mcp_tools = await get_all_mcp_tools(tenant_id) except Exception as e: @@ -694,10 +706,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": @@ -753,7 +787,6 @@ def _validate_langchain_tool( ToolExecutionException: If tool execution fails """ try: - from utils.langchain_utils import discover_langchain_modules # Discover all LangChain tools discovered_tools = discover_langchain_modules() @@ -804,7 +837,7 @@ async def validate_tool_impl( tool_name, inputs, source, usage, params = ( request.name, request.inputs, request.source, request.usage, request.params) if source == ToolSourceEnum.MCP.value: - if usage == "nexent": + if usage == "outer-apis": return await _validate_mcp_tool_nexent(tool_name, inputs) else: return await _validate_mcp_tool_remote(tool_name, inputs, usage, tenant_id) @@ -824,3 +857,396 @@ async def validate_tool_impl( except Exception as e: logger.error(f"Validate Tool failed: {e}") raise ToolExecutionException(str(e)) + + +# -------------------------------------------------- +# Outer API Tools (OpenAPI to MCP Conversion) +# -------------------------------------------------- + +def parse_openapi_to_mcp_tools(openapi_json: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Parse OpenAPI JSON and convert it to a list of MCP tool definitions. + + Args: + openapi_json: OpenAPI 3.x specification as dictionary + + Returns: + List of tool definition dictionaries suitable for storage and MCP registration + """ + tools = [] + paths = openapi_json.get("paths", {}) + + servers = openapi_json.get("servers", []) + base_url = servers[0].get("url", "") if servers else "" + + components = openapi_json.get("components", {}) + schemas = components.get("schemas", {}) + + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + + for method, operation in path_item.items(): + if method.upper() not in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]: + continue + + if not isinstance(operation, dict): + continue + + operation_id = operation.get("operationId") or _generate_operation_id(method.upper(), path) + tool_name = _sanitize_function_name(operation_id) + + summary = operation.get("summary", "") + description = operation.get("description", summary) + if not description: + description = f"{method.upper()} {path}" + + input_schema = _parse_request_body(operation, schemas) + + full_url = base_url.rstrip("/") + "/" + path.lstrip("/") if base_url else path + + tool_def = { + "name": tool_name, + "description": description, + "method": method.upper(), + "url": full_url, + "headers_template": {}, + "query_template": _parse_parameters(operation.get("parameters", []), "query"), + "body_template": _parse_request_body_template(operation, schemas), + "input_schema": input_schema + } + tools.append(tool_def) + + return tools + + +def _generate_operation_id(method: str, path: str) -> str: + """Generate operation ID from method and path.""" + path_clean = path.strip("/").replace("/", "_").replace("-", "_").replace("{", "").replace("}", "") + return f"{method.lower()}_{path_clean}" + + +def _resolve_ref(ref: str, schemas: Dict[str, Any]) -> Dict[str, Any]: + """ + Resolve a $ref reference to its actual schema. + + Args: + ref: Reference string like "#/components/schemas/CountOutput" + schemas: Dictionary of schemas from components (already extracted from components/schemas) + + Returns: + Resolved schema dictionary + """ + if not ref.startswith("#/"): + return {} + + parts = ref.lstrip("#/").split("/") + + if len(parts) >= 2 and parts[-2] == "schemas": + schema_name = parts[-1] + if schema_name in schemas: + return schemas[schema_name] + return {} + + if len(parts) == 1: + schema_name = parts[0] + if schema_name in schemas: + return schemas[schema_name] + return {} + + return {} + + +def _resolve_schema(schema: Dict[str, Any], schemas: Dict[str, Any], depth: int = 0) -> Dict[str, Any]: + """ + Recursively resolve schema, handling $ref and nested schemas. + + Args: + schema: Schema dictionary, possibly containing $ref + schemas: Dictionary of schemas from components + depth: Current recursion depth to prevent infinite loops + + Returns: + Fully resolved schema dictionary + """ + if depth > 10: + return schema + + if "$ref" in schema: + resolved = _resolve_ref(schema["$ref"], schemas) + return _resolve_schema(resolved, schemas, depth + 1) + + result = schema.copy() + + if "items" in result: + result["items"] = _resolve_schema(result["items"], schemas, depth + 1) + + if "properties" in result: + resolved_properties = {} + for prop_name, prop_schema in result["properties"].items(): + resolved_properties[prop_name] = _resolve_schema(prop_schema, schemas, depth + 1) + result["properties"] = resolved_properties + + if "allOf" in result: + resolved_allof = [] + for sub_schema in result["allOf"]: + resolved_allof.append(_resolve_schema(sub_schema, schemas, depth + 1)) + result["allOf"] = resolved_allof + + if "anyOf" in result: + resolved_anyof = [] + for sub_schema in result["anyOf"]: + resolved_anyof.append(_resolve_schema(sub_schema, schemas, depth + 1)) + result["anyOf"] = resolved_anyof + + if "oneOf" in result: + resolved_oneof = [] + for sub_schema in result["oneOf"]: + resolved_oneof.append(_resolve_schema(sub_schema, schemas, depth + 1)) + result["oneOf"] = resolved_oneof + + return result + + +def _parse_parameters(parameters: List[Dict], param_type: str) -> Dict[str, Any]: + """Parse OpenAPI parameters of specified type.""" + result = {} + for param in parameters: + if param.get("in") == param_type: + param_name = param.get("name", "") + schema = param.get("schema", {"type": "string"}) + result[param_name] = { + "required": param.get("required", False), + "description": param.get("description", ""), + "schema": schema + } + return result + + +def _parse_request_body(operation: Dict[str, Any], schemas: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse OpenAPI requestBody to MCP input schema. + Handles $ref references and nested schemas. + + Args: + operation: OpenAPI operation dictionary + schemas: Dictionary of schemas from components + + Returns: + MCP-compatible input schema + """ + input_schema = { + "type": "object", + "properties": {}, + "required": [] + } + + parameters = operation.get("parameters", []) + for param in parameters: + if param.get("in") == "query": + param_name = param.get("name", "") + schema = param.get("schema", {"type": "string"}) + resolved_schema = _resolve_schema(schema, schemas) + input_schema["properties"][param_name] = { + "type": resolved_schema.get("type", "string"), + "description": param.get("description", "") + } + if param.get("required"): + input_schema["required"].append(param_name) + + request_body = operation.get("requestBody", {}) + if request_body: + content = request_body.get("content", {}) + json_content = content.get("application/json", {}) + json_schema = json_content.get("schema", {}) + + resolved_schema = _resolve_schema(json_schema, schemas) + + if resolved_schema.get("type") == "object" and "properties" in resolved_schema: + for prop_name, prop_schema in resolved_schema["properties"].items(): + if prop_name not in input_schema["properties"]: + input_schema["properties"][prop_name] = { + "type": prop_schema.get("type", "string"), + "description": prop_schema.get("description", "") + } + + required_props = resolved_schema.get("required", []) + for req in required_props: + if req not in input_schema["required"]: + input_schema["required"].append(req) + + return input_schema + + +def _parse_request_body_template(operation: Dict[str, Any], schemas: Dict[str, Any]) -> Dict[str, Any]: + """ + Parse OpenAPI requestBody to extract template for request body. + Handles $ref references. + + Args: + operation: OpenAPI operation dictionary + schemas: Dictionary of schemas from components + + Returns: + Template dictionary with default values + """ + request_body = operation.get("requestBody", {}) + if not request_body: + return {} + + content = request_body.get("content", {}) + json_content = content.get("application/json", {}) + json_schema = json_content.get("schema", {}) + + resolved_schema = _resolve_schema(json_schema, schemas) + + if resolved_schema.get("type") == "object" and "properties" in resolved_schema: + template = {} + for prop_name, prop_schema in resolved_schema["properties"].items(): + default_value = prop_schema.get("example") or prop_schema.get("default") + if default_value is not None: + template[prop_name] = default_value + return template + + return {} + + +def import_openapi_json(openapi_json: Dict[str, Any], tenant_id: str, user_id: str) -> Dict[str, Any]: + """ + Import OpenAPI JSON and convert/sync tools to database. + + Args: + openapi_json: OpenAPI 3.x specification as dictionary + tenant_id: Tenant ID for multi-tenancy + user_id: User ID for audit + + Returns: + Dictionary with import result (created, updated, deleted counts) + """ + tools = parse_openapi_to_mcp_tools(openapi_json) + result = sync_outer_api_tools(tools, tenant_id, user_id) + result["total_tools"] = len(tools) + logger.info(f"Imported {len(tools)} tools from OpenAPI JSON for tenant {tenant_id}") + return result + + +def list_outer_api_tools(tenant_id: str) -> List[Dict[str, Any]]: + """ + List all outer API tools for a tenant. + + Args: + tenant_id: Tenant ID + + Returns: + List of tool dictionaries + """ + return query_outer_api_tools_by_tenant(tenant_id) + + +def get_outer_api_tool(tool_id: int, tenant_id: str) -> Optional[Dict[str, Any]]: + """ + Get a specific outer API tool by ID. + + Args: + tool_id: Tool ID + tenant_id: Tenant ID + + Returns: + Tool dictionary or None + """ + return query_outer_api_tool_by_id(tool_id, tenant_id) + + +def delete_outer_api_tool(tool_id: int, tenant_id: str, user_id: str) -> bool: + """ + Delete an outer API tool. + + Args: + tool_id: Tool ID + tenant_id: Tenant ID + user_id: User ID for audit + + Returns: + True if deleted, False if not found + """ + # Get tool info before deletion to get the tool name + tool_info = query_outer_api_tool_by_id(tool_id, tenant_id) + tool_name = tool_info.get("name") if tool_info else None + + # Delete from database + deleted = db_delete_outer_api_tool(tool_id, tenant_id, user_id) + + if deleted and tool_name: + # Also remove from MCP server + _remove_outer_api_tool_from_mcp(tool_name, tenant_id) + + return deleted + + +def _remove_outer_api_tool_from_mcp(tool_name: str, tenant_id: str) -> bool: + """ + Remove a specific outer API tool from the MCP server via HTTP API. + + Args: + tool_name: Name of the tool to remove + tenant_id: Tenant ID + + Returns: + True if removed successfully, False otherwise + """ + remove_url = f"{MCP_MANAGEMENT_API}/tools/outer_api/{tool_name}" + try: + response = requests.delete(remove_url, timeout=10) + if response.ok: + logger.info(f"Removed outer API tool '{tool_name}' from MCP server") + return True + else: + logger.warning(f"Failed to remove tool '{tool_name}' from MCP: {response.status_code}") + return False + except requests.RequestException as e: + logger.warning(f"Failed to remove tool '{tool_name}' from MCP: {e}") + return False + + +def _refresh_outer_api_tools_in_mcp(tenant_id: str) -> Dict[str, Any]: + """ + Refresh outer API tools in MCP server via HTTP API. + + Includes retry logic to handle cases where the MCP Server's management API + might not be fully ready immediately after a restart. + + Args: + tenant_id: Tenant ID + + Returns: + Dictionary with refresh result + """ + refresh_url = f"{MCP_MANAGEMENT_API}/tools/outer_api/refresh" + max_retries = 3 + retry_delay = 1.0 + + for attempt in range(max_retries): + try: + response = requests.post( + refresh_url, + params={"tenant_id": tenant_id}, + timeout=30 + ) + response.raise_for_status() + result = response.json() + logger.info(f"Refreshed outer API tools for tenant {tenant_id}: {result}") + return result.get("data", {}) + except requests.RequestException as e: + if attempt < max_retries - 1: + logger.warning( + f"Failed to refresh outer API tools (attempt {attempt + 1}/{max_retries}): {e}. " + f"Retrying in {retry_delay}s..." + ) + time.sleep(retry_delay) + retry_delay *= 2 + else: + logger.error(f"Failed to refresh outer API tools after {max_retries} attempts: {e}") + return {"error": str(e)} + except Exception as e: + logger.warning(f"Failed to refresh outer API tools in MCP: {e}") + return {"error": str(e)} 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/file_management_utils.py b/backend/utils/file_management_utils.py index 57025e350..7d31a74bb 100644 --- a/backend/utils/file_management_utils.py +++ b/backend/utils/file_management_utils.py @@ -11,7 +11,7 @@ import requests from fastapi import UploadFile -from consts.const import DATA_PROCESS_SERVICE +from consts.const import DATA_PROCESS_SERVICE, LIBREOFFICE_PROFILE_DIR from consts.model import ProcessParams from database.attachment_db import get_file_size_from_minio from utils.auth_utils import get_current_user_id @@ -20,6 +20,14 @@ logger = logging.getLogger("file_management_utils") +def ensure_secure_libreoffice_profile_dir(profile_dir: str) -> Path: + """Create the shared LibreOffice profile directory with owner-only permissions.""" + profile_path = Path(profile_dir).expanduser().resolve() + profile_path.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(profile_path, 0o700) + return profile_path + + async def save_upload_file(file: UploadFile, upload_path: Path) -> bool: try: async with aiofiles.open(upload_path, 'wb') as out_file: @@ -357,11 +365,21 @@ async def convert_office_to_pdf(input_path: str, output_dir: str, timeout: int = def _run_libreoffice_conversion(): """Synchronous LibreOffice conversion to run in thread executor.""" + profile_uri = ensure_secure_libreoffice_profile_dir( + LIBREOFFICE_PROFILE_DIR + ).as_uri() cmd = [ - 'libreoffice', - '--headless', - '--convert-to', 'pdf', - '--outdir', output_dir, + "soffice", + "--headless", + "--nologo", + "--nodefault", + "--nofirststartwizard", + "--norestore", + "--invisible", + "--nolockcheck", + f"-env:UserInstallation={profile_uri}", + "--convert-to", "pdf", + "--outdir", output_dir, input_path ] return subprocess.run( @@ -401,4 +419,3 @@ def _run_libreoffice_conversion(): "LibreOffice is not installed or not available in PATH. " ) from e - diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index b12ba19a5..643e6cd40 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -1,6 +1,6 @@ import logging import os -from typing import Dict, Any +from typing import Dict, Any, Optional import yaml @@ -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,64 @@ 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"], + existing_skill: Optional[Dict[str, Any]] = None +) -> Dict[str, str]: + """ + Get skill creation simple prompt template with Jinja2 rendering. + + This template is structured YAML with system_prompt and user_prompt sections. + Supports Jinja2 template syntax for dynamic content based on existing_skill. + + Args: + language: Language code ('zh' or 'en') + existing_skill: Optional dict containing existing skill info for update scenarios. + Expected keys: name, description, tags, content + + Returns: + Dict[str, str]: Template with keys 'system_prompt' and 'user_prompt', rendered with variables + """ + from jinja2 import Template + + 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) + + # Prepare template context with existing_skill info + context = { + "existing_skill": existing_skill + } + + # Render templates with Jinja2 + system_prompt_raw = template_data.get("system_prompt", "") + user_prompt_raw = template_data.get("user_prompt", "") + + try: + system_prompt = Template(system_prompt_raw).render(**context) + except Exception as e: + logger.warning(f"Failed to render system_prompt template: {e}, using raw content") + system_prompt = system_prompt_raw + + try: + user_prompt = Template(user_prompt_raw).render(**context) + except Exception as e: + logger.warning(f"Failed to render user_prompt template: {e}, using raw content") + user_prompt = user_prompt_raw + + return { + "system_prompt": system_prompt, + "user_prompt": 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/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..1df19ac4d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -44,6 +44,7 @@ RUNTIME_SERVICE_URL=http://nexent-runtime:5014 # MCP service (port 5011) - MCP protocol service NEXENT_MCP_SERVER=http://nexent-mcp:5011 +MCP_MANAGEMENT_API=http://nexent-mcp:5015 # Data process service (port 5012) - Data processing service DATA_PROCESS_SERVICE=http://nexent-data-process:5012/api @@ -155,4 +156,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..89088f2c3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -142,6 +142,7 @@ services: restart: always ports: - "5011:5011" # MCP service port + - "5015:5015" # MCP management API port volumes: - ${NEXENT_USER_DIR:-$HOME/nexent}:/mnt/nexent - ${ROOT_DIR}/openssh-server/ssh-keys:/opt/ssh-keys:ro @@ -205,7 +206,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/docker/generate_env.sh b/docker/generate_env.sh index b98e72737..962102f1d 100755 --- a/docker/generate_env.sh +++ b/docker/generate_env.sh @@ -151,6 +151,13 @@ update_env_file() { echo "NORTHBOUND_API_SERVER=http://localhost:5013/api" >> ../.env fi + # MCP_MANAGEMENT_API + if grep -q "^MCP_MANAGEMENT_API=" ../.env; then + sed -i.bak "s~^MCP_MANAGEMENT_API=.*~MCP_MANAGEMENT_API=http://localhost:5015~" ../.env + else + echo "MCP_MANAGEMENT_API=http://localhost:5015" >> ../.env + fi + # MINIO_ENDPOINT if grep -q "^MINIO_ENDPOINT=" ../.env; then sed -i.bak "s~^MINIO_ENDPOINT=.*~MINIO_ENDPOINT=http://localhost:9010~" ../.env diff --git a/docker/init.sql b/docker/init.sql index 75e9a818f..26c345b69 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -1167,3 +1167,71 @@ COMMENT ON COLUMN nexent.ag_skill_instance_t.create_time IS 'Creation timestamp' COMMENT ON COLUMN nexent.ag_skill_instance_t.updated_by IS 'Last updater ID'; COMMENT ON COLUMN nexent.ag_skill_instance_t.update_time IS 'Last update timestamp'; COMMENT ON COLUMN nexent.ag_skill_instance_t.delete_flag IS 'Whether it is deleted. Optional values: Y/N'; + +-- Create the ag_outer_api_tools table for outer API tools (OpenAPI to MCP conversion) +CREATE TABLE IF NOT EXISTS nexent.ag_outer_api_tools ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + method VARCHAR(10), + url TEXT NOT NULL, + headers_template JSONB DEFAULT '{}', + query_template JSONB DEFAULT '{}', + body_template JSONB DEFAULT '{}', + input_schema JSONB DEFAULT '{}', + tenant_id VARCHAR(100), + is_available BOOLEAN DEFAULT TRUE, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +ALTER TABLE nexent.ag_outer_api_tools OWNER TO "root"; + +-- Create a function to update the update_time column +CREATE OR REPLACE FUNCTION update_ag_outer_api_tools_update_time() +RETURNS TRIGGER AS $$ +BEGIN + NEW.update_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create a trigger to call the function before each update +CREATE TRIGGER update_ag_outer_api_tools_update_time_trigger +BEFORE UPDATE ON nexent.ag_outer_api_tools +FOR EACH ROW +EXECUTE FUNCTION update_ag_outer_api_tools_update_time(); + +-- Add comment to the table +COMMENT ON TABLE nexent.ag_outer_api_tools IS 'Outer API tools table - stores converted OpenAPI tools as MCP tools'; + +-- Add comments to the columns +COMMENT ON COLUMN nexent.ag_outer_api_tools.id IS 'Tool ID, unique primary key'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.name IS 'Tool name (unique identifier)'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.description IS 'Tool description'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.method IS 'HTTP method: GET/POST/PUT/DELETE/PATCH'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.url IS 'API endpoint URL (full path with base URL)'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.headers_template IS 'Headers template as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.query_template IS 'Query parameters template as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.body_template IS 'Request body template as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.input_schema IS 'MCP input schema as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.tenant_id IS 'Tenant ID for multi-tenancy'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.is_available IS 'Whether the tool is available'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.create_time IS 'Creation time'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.created_by IS 'Creator'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.updated_by IS 'Updater'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.delete_flag IS 'Whether it is deleted. Optional values: Y/N'; + +-- Create index for tenant_id queries +CREATE INDEX IF NOT EXISTS idx_ag_outer_api_tools_tenant_id +ON nexent.ag_outer_api_tools (tenant_id) +WHERE delete_flag = 'N'; + +-- Create index for name queries +CREATE INDEX IF NOT EXISTS idx_ag_outer_api_tools_name +ON nexent.ag_outer_api_tools (name) +WHERE delete_flag = 'N'; diff --git a/docker/sql/v2.0.1_0331_add_outer_api_tool_t.sql b/docker/sql/v2.0.1_0331_add_outer_api_tool_t.sql new file mode 100644 index 000000000..b6e055775 --- /dev/null +++ b/docker/sql/v2.0.1_0331_add_outer_api_tool_t.sql @@ -0,0 +1,70 @@ +-- v2.0.1_0331_add_outer_api_tool_t.sql +-- Create table for outer API tools (OpenAPI to MCP conversion) + +-- Create the ag_outer_api_tools table in the nexent schema +CREATE TABLE IF NOT EXISTS nexent.ag_outer_api_tools ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + method VARCHAR(10), + url TEXT NOT NULL, + headers_template JSONB DEFAULT '{}', + query_template JSONB DEFAULT '{}', + body_template JSONB DEFAULT '{}', + input_schema JSONB DEFAULT '{}', + tenant_id VARCHAR(100), + is_available BOOLEAN DEFAULT TRUE, + create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + updated_by VARCHAR(100), + delete_flag VARCHAR(1) DEFAULT 'N' +); + +ALTER TABLE nexent.ag_outer_api_tools OWNER TO "root"; + +-- Create a function to update the update_time column +CREATE OR REPLACE FUNCTION update_ag_outer_api_tools_update_time() +RETURNS TRIGGER AS $$ +BEGIN + NEW.update_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create a trigger to call the function before each update +CREATE TRIGGER update_ag_outer_api_tools_update_time_trigger +BEFORE UPDATE ON nexent.ag_outer_api_tools +FOR EACH ROW +EXECUTE FUNCTION update_ag_outer_api_tools_update_time(); + +-- Add comment to the table +COMMENT ON TABLE nexent.ag_outer_api_tools IS 'Outer API tools table - stores converted OpenAPI tools as MCP tools'; + +-- Add comments to the columns +COMMENT ON COLUMN nexent.ag_outer_api_tools.id IS 'Tool ID, unique primary key'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.name IS 'Tool name (unique identifier)'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.description IS 'Tool description'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.method IS 'HTTP method: GET/POST/PUT/DELETE/PATCH'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.url IS 'API endpoint URL (full path with base URL)'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.headers_template IS 'Headers template as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.query_template IS 'Query parameters template as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.body_template IS 'Request body template as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.input_schema IS 'MCP input schema as JSONB'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.tenant_id IS 'Tenant ID for multi-tenancy'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.is_available IS 'Whether the tool is available'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.create_time IS 'Creation time'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.update_time IS 'Update time'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.created_by IS 'Creator'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.updated_by IS 'Updater'; +COMMENT ON COLUMN nexent.ag_outer_api_tools.delete_flag IS 'Whether it is deleted. Optional values: Y/N'; + +-- Create index for tenant_id queries +CREATE INDEX IF NOT EXISTS idx_ag_outer_api_tools_tenant_id +ON nexent.ag_outer_api_tools (tenant_id) +WHERE delete_flag = 'N'; + +-- Create index for name queries +CREATE INDEX IF NOT EXISTS idx_ag_outer_api_tools_name +ON nexent.ag_outer_api_tools (name) +WHERE delete_flag = 'N'; diff --git a/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx index ebf3c99b5..04c7f2efa 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/McpConfigModal.tsx @@ -22,12 +22,13 @@ import { Eye, Plus, LoaderCircle, - RefreshCw, FileText, Container, Upload as UploadIcon, Unplug, Settings, + Import, + RefreshCw, } from "lucide-react"; import { McpConfigModalProps } from "@/types/agentConfig"; @@ -37,6 +38,9 @@ import { useMcpConfig } from "@/hooks/useMcpConfig"; import McpToolListModal from "@/components/mcp/McpToolListModal"; import McpEditServerModal from "@/components/mcp/McpEditServerModal"; import McpContainerLogsModal from "@/components/mcp/McpContainerLogsModal"; +import { API_ENDPOINTS } from "@/services/api"; +import { getAuthHeaders } from "@/lib/auth"; +import log from "@/lib/logger"; const { Text, Title } = Typography; @@ -58,6 +62,7 @@ export default function McpConfigModal({ healthCheckLoading, loadServerList, loadContainerList, + refreshToolsAndAgents, handleAddServer, handleDeleteServer, handleViewTools, @@ -70,6 +75,12 @@ export default function McpConfigModal({ handleGetMcpRecord, } = useMcpConfig({ enabled: visible }); + // OpenAPI to MCP state + const [openApiJson, setOpenApiJson] = useState(""); + const [importingOpenApi, setImportingOpenApi] = useState(false); + const [outerApiTools, setOuterApiTools] = useState([]); + const [loadingOuterApiTools, setLoadingOuterApiTools] = useState(false); + // Local UI state const [addingServer, setAddingServer] = useState(false); const [newServerName, setNewServerName] = useState(""); @@ -417,6 +428,105 @@ export default function McpConfigModal({ setLogsModalVisible(true); }; + // OpenAPI to MCP handlers + const loadOuterApiTools = async () => { + setLoadingOuterApiTools(true); + try { + const response = await fetch(API_ENDPOINTS.tool.outerApiTools, { + headers: getAuthHeaders(), + }); + const result = await response.json(); + if (result.data) { + setOuterApiTools(result.data); + } else { + message.error(t("mcpConfig.openApiToMcp.message.loadToolsFailed")); + } + } catch (error) { + log.error("Failed to load outer API tools:", error); + message.error(t("mcpConfig.openApiToMcp.message.loadToolsFailed")); + } + setLoadingOuterApiTools(false); + }; + + const onImportOpenApi = async () => { + if (!openApiJson.trim()) { + message.error(t("mcpConfig.openApiToMcp.jsonPlaceholder")); + return; + } + + let parsedJson; + try { + parsedJson = JSON.parse(openApiJson); + } catch { + message.error(t("mcpConfig.openApiToMcp.message.invalidJson")); + return; + } + + setImportingOpenApi(true); + try { + const response = await fetch(API_ENDPOINTS.tool.importOpenapi, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify(parsedJson), + }); + + if (response.ok) { + message.success(t("mcpConfig.openApiToMcp.message.importSuccess")); + setOpenApiJson(""); + await loadOuterApiTools(); + await refreshToolsAndAgents(); + } else { + const errorData = await response.json(); + message.error( + errorData.detail || t("mcpConfig.openApiToMcp.message.importFailed") + ); + } + } catch (error) { + log.error("Failed to import OpenAPI:", error); + message.error(t("mcpConfig.openApiToMcp.message.importFailed")); + } + setImportingOpenApi(false); + }; + + const onDeleteOuterApiTool = (tool: any) => { + confirm({ + title: t("mcpConfig.delete.confirmTitle"), + content: t("mcpConfig.delete.confirmContent", { + name: tool.name, + }), + okText: t("common.delete", "Delete"), + onOk: async () => { + try { + const response = await fetch( + API_ENDPOINTS.tool.deleteOuterApiTool(tool.id), + { + method: "DELETE", + headers: getAuthHeaders(), + } + ); + + if (response.ok) { + message.success( + t("mcpConfig.openApiToMcp.message.deleteSuccess") + ); + await loadOuterApiTools(); + await refreshToolsAndAgents(); + } else { + message.error( + t("mcpConfig.openApiToMcp.message.deleteFailed") + ); + } + } catch (error) { + log.error("Failed to delete outer API tool:", error); + message.error(t("mcpConfig.openApiToMcp.message.deleteFailed")); + } + }, + }); + }; + // Server list table columns const serverColumns = [ { @@ -589,6 +699,52 @@ export default function McpConfigModal({ }, ]; + // Outer API tools table columns + const outerApiToolsColumns = [ + { + title: t("mcpConfig.openApiToMcp.toolList.column.name"), + dataIndex: "name", + key: "name", + width: "35%", + ellipsis: true, + }, + { + title: t("mcpConfig.openApiToMcp.toolList.column.description"), + dataIndex: "description", + key: "description", + width: "45%", + ellipsis: true, + }, + { + title: t("mcpConfig.openApiToMcp.toolList.column.action"), + key: "action", + width: "20%", + render: (_: any, record: any) => ( + + {renderPermissionControlledButton({ + isReadOnly: false, + button: { + type: "link", + danger: true, + icon: , + onClick: () => onDeleteOuterApiTool(record), + size: "small", + disabled: actionsLocked, + children: t("mcpConfig.serverList.button.delete"), + }, + })} + + ), + }, + ]; + + // Load outer API tools when modal opens + useEffect(() => { + if (visible) { + loadOuterApiTools(); + } + }, [visible]); + return ( <> + + {t("mcpConfig.openApiToMcp.title")} + + ), + children: ( + + +
+ setOpenApiJson(e.target.value)} + rows={6} + disabled={actionsLocked || importingOpenApi} + style={{ fontFamily: "monospace", fontSize: 12 }} + /> +
+
+ +
+
+
+ ), + }, ]} /> @@ -1003,6 +1218,30 @@ export default function McpConfigModal({ style={{ width: "100%" }} /> + + {/* Outer API Tools list */} +
+
+ + {t("mcpConfig.openApiToMcp.toolList.title")} + +
+ + diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx index 46307a0d2..eff41b6d9 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,127 @@ 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( { - query: currentInput, - conversation_id: 0, - history, - agent_id: agentId, - is_debug: true, + user_request: userPrompt, + existing_skill: !isCreateMode ? { + name: formValues.name || "", + description: formValues.description || "", + tags: formValues.tags || [], + content: formValues.content || "", + } : undefined, }, - 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 +487,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 +543,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 +683,16 @@ export default function SkillBuildModal({ label={t("skillManagement.form.content")} >