From 8df4c9ce8bce5177860eb1ba4b8ddabf0b63b183 Mon Sep 17 00:00:00 2001 From: shaohuzhang1 Date: Fri, 30 Oct 2026 17:01:12 +0800 Subject: [PATCH] feat: Export Applied Skills --- apps/application/api/application_api.py | 74 +++-- apps/application/serializers/application.py | 12 + .../serializers/application_skill.py | 203 ++++++++++++++ .../template/skill/skill_script.py | 259 ++++++++++++++++++ apps/application/urls.py | 1 + apps/application/views/application.py | 34 +++ apps/locales/en_US/LC_MESSAGES/django.po | 6 + apps/locales/zh_CN/LC_MESSAGES/django.po | 6 + apps/locales/zh_Hant/LC_MESSAGES/django.po | 6 + apps/oss/serializers/file.py | 11 +- ui/src/api/API_README.md | 7 +- .../application/application.ts | 8 +- .../workspace/application/application.ts | 7 +- ui/src/views/VIEW_README.md | 4 +- .../ExportApplicationAction.vue | 50 +++- 15 files changed, 642 insertions(+), 46 deletions(-) create mode 100644 apps/application/serializers/application_skill.py create mode 100644 apps/application/template/skill/skill_script.py diff --git a/apps/application/api/application_api.py b/apps/application/api/application_api.py index 7cbbb24c0a0..8c4f3581beb 100644 --- a/apps/application/api/application_api.py +++ b/apps/application/api/application_api.py @@ -1,19 +1,27 @@ # coding=utf-8 """ - @project: MaxKB - @Author:虎虎 - @file: application.py - @date:2025/5/26 16:59 - @desc: +@project: MaxKB +@Author:虎虎 +@file: application.py +@date:2025/5/26 16:59 +@desc: """ + from django.utils.translation import gettext_lazy as _ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter from rest_framework import serializers -from application.serializers.application import ApplicationCreateSerializer, ApplicationListResponse, \ - ApplicationImportRequest, ApplicationEditSerializer, TextToSpeechRequest, SpeechToTextRequest, PlayDemoTextRequest, \ - BatchCleanTimeSerializer +from application.serializers.application import ( + ApplicationCreateSerializer, + ApplicationListResponse, + ApplicationImportRequest, + ApplicationEditSerializer, + TextToSpeechRequest, + SpeechToTextRequest, + PlayDemoTextRequest, + BatchCleanTimeSerializer, +) from common.mixins.api_mixin import APIMixin from common.result import ResultSerializer, ResultPageSerializer, DefaultResultSerializer from knowledge.serializers.common import BatchSerializer, BatchMoveSerializer @@ -46,58 +54,58 @@ def get_parameters(): name="workspace_id", description="工作空间id", type=OpenApiTypes.STR, - location='path', + location="path", required=True, ), OpenApiParameter( name="current_page", description=_("Current page"), type=OpenApiTypes.INT, - location='path', + location="path", required=True, ), OpenApiParameter( name="page_size", description=_("Page size"), type=OpenApiTypes.INT, - location='path', + location="path", required=True, ), OpenApiParameter( name="folder_id", description=_("folder id"), type=OpenApiTypes.STR, - location='query', + location="query", required=False, ), OpenApiParameter( name="name", description=_("Application Name"), type=OpenApiTypes.STR, - location='query', + location="query", required=False, ), OpenApiParameter( name="desc", description=_("Application Description"), type=OpenApiTypes.STR, - location='query', + location="query", required=False, ), OpenApiParameter( name="user_id", description=_("User ID"), type=OpenApiTypes.STR, - location='query', + location="query", required=False, ), OpenApiParameter( name="publish_status", - description=_("Publish status") + '(published|unpublished)', + description=_("Publish status") + "(published|unpublished)", type=OpenApiTypes.STR, - location='query', + location="query", required=False, - ) + ), ] @staticmethod @@ -117,7 +125,7 @@ def get_parameters(): name="workspace_id", description="工作空间id", type=OpenApiTypes.STR, - location='path', + location="path", required=True, ) ] @@ -149,16 +157,16 @@ def get_parameters(): name="workspace_id", description="工作空间id", type=OpenApiTypes.STR, - location='path', + location="path", required=True, ), OpenApiParameter( name="application_id", description="应用id", type=OpenApiTypes.STR, - location='path', + location="path", required=True, - ) + ), ] @@ -170,10 +178,11 @@ def get_parameters(): name="workspace_id", description="工作空间id", type=OpenApiTypes.STR, - location='path', + location="path", required=True, ) ] + @staticmethod def get_request(): return BatchSerializer @@ -203,6 +212,25 @@ def get_request(): return ApplicationEditSerializer +class ApplicationExportSkillsAPI(APIMixin): + @staticmethod + def get_parameters(): + return [ + *ApplicationOperateAPI.get_parameters(), + OpenApiParameter( + name="base_url", + description="MaxKB 访问地址(协议、主机、端口,可带路径前缀),缺省使用当前请求地址", + type=OpenApiTypes.STR, + location="query", + required=False, + ), + ] + + @staticmethod + def get_response(): + return DefaultResultSerializer + + class TextToSpeechAPI(APIMixin): @staticmethod def get_parameters(): diff --git a/apps/application/serializers/application.py b/apps/application/serializers/application.py index b19a69969b7..7e6c45f32d1 100644 --- a/apps/application/serializers/application.py +++ b/apps/application/serializers/application.py @@ -1233,6 +1233,18 @@ def export(self, with_valid=True): except Exception as e: return result.error(str(e), response_status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def export_skills(self, site_url, with_valid=True): + if with_valid: + self.is_valid() + from application.serializers.application_skill import build_application_skill_zip, get_chat_base_url + + base_url = get_chat_base_url(site_url) + application = QuerySet(Application).filter(id=self.data.get("application_id")).first() + skill_name, content = build_application_skill_zip(application, base_url) + response = HttpResponse(content_type="application/zip", content=content) + response["Content-Disposition"] = f'attachment; filename="{skill_name}.zip"' + return response + @staticmethod def to_tool_dict(tool, tool_workflow_dict): if tool.tool_type == ToolType.WORKFLOW: diff --git a/apps/application/serializers/application_skill.py b/apps/application/serializers/application_skill.py new file mode 100644 index 00000000000..62c0b6ba1bc --- /dev/null +++ b/apps/application/serializers/application_skill.py @@ -0,0 +1,203 @@ +# coding=utf-8 +""" +@project: MaxKB +@file: application_skill.py +@desc: 将应用导出为 Skills 压缩包 +""" + +import io +import json +import os +import re +import zipfile +from urllib.parse import urlsplit + +from django.utils.translation import gettext_lazy as _ +from rest_framework import status + +from common.exception.app_exception import AppApiException +from maxkb.conf import PROJECT_DIR +from maxkb.const import CONFIG + +SKILL_SCRIPT_PATH = os.path.join(PROJECT_DIR, "apps", "application", "template", "skill", "skill_script.py") + + +class InvalidSkillBaseUrl(AppApiException): + # 以 400 返回,前端据此让用户手动填写访问地址 + status_code = status.HTTP_400_BAD_REQUEST + + def __init__(self): + super().__init__(status.HTTP_400_BAD_REQUEST, _("Invalid MaxKB address")) + + +def get_chat_base_url(site_url): + """校验 MaxKB 站点地址(协议 + 主机 + 端口,可带路径前缀)并拼上对话路径。 + 地址会写入 .env,必须拒绝换行等字符,避免注入额外配置""" + site_url = (site_url or "").strip() + if not site_url or re.search(r"\s", site_url): + raise InvalidSkillBaseUrl() + try: + parts = urlsplit(site_url) + parts.port # 非法端口在此抛出 ValueError + except ValueError: + raise InvalidSkillBaseUrl() + if parts.scheme not in ("http", "https") or not parts.hostname or parts.query or parts.fragment: + raise InvalidSkillBaseUrl() + return f"{site_url.rstrip('/')}{CONFIG.get_chat_path()}" + + +def get_skill_name(application): + # Skills 规范要求 name 仅含小写字母、数字和连字符,中文名称回退为应用 ID + name = re.sub(r"[^a-z0-9]+", "-", (application.name or "").lower()).strip("-") + return (name or f"application-{str(application.id)[:8]}")[:64] + + +def get_user_input_fields(application): + nodes = (application.work_flow or {}).get("nodes") or [] + base_node = next((n for n in nodes if n.get("type") == "base-node"), None) + if base_node is None: + return [] + return (base_node.get("properties") or {}).get("user_input_field_list") or [] + + +def build_skill_md(application, skill_name): + description = " ".join((application.desc or application.name or "").split()) + description = f'{description} Use this skill to ask the MaxKB application "{application.name}".'[:1024] + lines = [ + "---", + f"name: {skill_name}", + # JSON 字符串即合法的 YAML 双引号字符串,避免描述中的冒号等字符破坏 frontmatter + f"description: {json.dumps(description, ensure_ascii=False)}", + "---", + "", + f"# {application.name}", + "", + ] + if application.desc: + lines += [application.desc, ""] + lines += [ + "## Commands", + "", + "The script keeps a local list of conversations and a current conversation in " + "`.maxkb_state.json` in this skill's directory, per application. " + "Every command accepts `--application-id ` (defaults to `MAXKB_APPLICATION_ID`).", + "", + "### chat", + "", + "Send one message in the current conversation. If there is no current conversation, a new one is " + "started and becomes current.", + "", + "```bash", + f"python {skill_name}/scripts/maxkb.py chat \"\" [--form-data '']", + "```", + "", + "- `--form-data`: JSON object of application input fields", + "", + "Prints JSON `{application_id, chat_id, answer}`. On application failure it also has `errors` " + "and exits with code 1.", + "", + "### open", + "", + "Start a new conversation and make it current.", + "", + "```bash", + f'python {skill_name}/scripts/maxkb.py open [--title ""]', + "```", + "", + "- `--title`: conversation title; defaults to the first message sent in it", + "", + "Prints JSON `{application_id, chat_id, title}`.", + "", + "### chats", + "", + "List the local conversations, oldest first.", + "", + "```bash", + f"python {skill_name}/scripts/maxkb.py chats", + "```", + "", + "Prints JSON `{application_id, current, chats: [{index, chat_id, title, created_at, current}]}`. " + "`title` is the title given to `open` or `rename`, otherwise the first message of the conversation.", + "", + "### switch", + "", + "Make a local conversation current.", + "", + "```bash", + f"python {skill_name}/scripts/maxkb.py switch <index or chat_id>", + "```", + "", + "Prints JSON `{application_id, chat_id, title}`.", + "", + "### rename", + "", + "Change the title of a local conversation.", + "", + "```bash", + f'python {skill_name}/scripts/maxkb.py rename <index or chat_id> "<title>"', + "```", + "", + "Prints JSON `{application_id, chat_id, title}`.", + "", + "### download", + "", + "Download a MaxKB file.", + "", + "```bash", + f"python {skill_name}/scripts/maxkb.py download <file_id or link> [--output-dir <dir>]", + "```", + "", + "- `<file_id or link>`: a file ID, or a link containing `oss/file/<file_id>` (e.g. `./oss/file/<id>`)", + "- `--output-dir`: defaults to the current directory", + "", + "Prints JSON `{file_id, path}`.", + "", + ] + fields = get_user_input_fields(application) + if fields: + lines += [ + "## Input fields", + "", + "Pass these as a JSON object with `--form-data`:", + "", + ] + for field in fields: + label = field.get("label") + if isinstance(label, dict): + label = label.get("label") + required = "required" if field.get("required") else "optional" + lines.append(f"- `{field.get('field')}` ({required}): {label or ''}".rstrip(": ")) + lines.append("") + lines += [ + "## Configuration", + "", + "Set `MAXKB_API_KEY` in `.env` to the chat user's API key (starting with `chat-`, created in the chat " + "page's user settings). The chat user must have access to this application. " + "Adjust `MAXKB_BASE_URL` if the MaxKB chat page is reached through another address.", + "", + ] + return "\n".join(lines) + + +def build_env(application, base_url): + return "\n".join( + [ + f"MAXKB_BASE_URL={base_url}", + f"MAXKB_APPLICATION_ID={application.id}", + "MAXKB_API_KEY=", + "", + ] + ) + + +def build_application_skill_zip(application, base_url): + """返回 (技能名称, zip 字节)。base_url 为对话入口地址,如 http://host/chat""" + skill_name = get_skill_name(application) + with open(SKILL_SCRIPT_PATH, encoding="utf-8") as f: + script = f.read() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + zip_file.writestr(f"{skill_name}/SKILL.md", build_skill_md(application, skill_name)) + zip_file.writestr(f"{skill_name}/scripts/maxkb.py", script) + zip_file.writestr(f"{skill_name}/.env", build_env(application, base_url)) + return skill_name, buffer.getvalue() diff --git a/apps/application/template/skill/skill_script.py b/apps/application/template/skill/skill_script.py new file mode 100644 index 00000000000..1c8563df673 --- /dev/null +++ b/apps/application/template/skill/skill_script.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Call MaxKB applications as a chat user: manage local conversations, chat, download files. Standard library only.""" + +import argparse +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FILE_PATTERN = re.compile(r"oss/file/([\w-]+)") +# 本地会话记录:{application_id: {"current": chat_id, "chats": [{chat_id, title, created_at}]}} +STATE_PATH = os.path.join(SKILL_DIR, ".maxkb_state.json") +TITLE_LENGTH = 50 + + +def load_env(): + env_path = os.path.join(SKILL_DIR, ".env") + if not os.path.exists(env_path): + return + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +def get_config(): + load_env() + base_url = os.environ.get("MAXKB_BASE_URL", "").rstrip("/") + api_key = os.environ.get("MAXKB_API_KEY", "") + if not base_url or not api_key: + sys.exit("MAXKB_BASE_URL and MAXKB_API_KEY must be set (see .env)") + return base_url, api_key + + +def open_url(request, base_url): + try: + return urllib.request.urlopen(request, timeout=600) + except urllib.error.HTTPError as e: + sys.exit(f"HTTP {e.code}: {e.read().decode('utf-8', errors='replace')}") + except urllib.error.URLError as e: + sys.exit(f"Cannot reach {base_url}: {e.reason}") + + +def print_json(data): + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +def get_application_id(args): + load_env() + application_id = args.application_id or os.environ.get("MAXKB_APPLICATION_ID", "") + if not application_id: + sys.exit("Pass --application-id or set MAXKB_APPLICATION_ID (see .env)") + return application_id + + +def load_state(): + if not os.path.exists(STATE_PATH): + return {} + with open(STATE_PATH, encoding="utf-8") as f: + return json.load(f) + + +def save_state(state): + # 先写临时文件再替换,避免中断时留下半个 JSON + fd, tmp_path = tempfile.mkstemp(dir=SKILL_DIR, prefix=".maxkb_state.") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + os.replace(tmp_path, STATE_PATH) + + +def new_chat(app_state, title=""): + # 新会话由客户端生成 chat_id,服务端首次对话时创建;未指定标题时由第一条消息补上 + chat = {"chat_id": str(uuid.uuid4()), "title": title, "created_at": time.strftime("%Y-%m-%d %H:%M:%S")} + app_state.setdefault("chats", []).append(chat) + app_state["current"] = chat["chat_id"] + return chat + + +def list_chats(app_state): + current = app_state.get("current") + return [ + {"index": index, **chat, "current": chat["chat_id"] == current} + for index, chat in enumerate(app_state.get("chats") or [], start=1) + ] + + +def find_chat(app_state, key): + """按 `chats` 中的序号或 chat_id 查找本地会话""" + chat = next( + ( + chat + for index, chat in enumerate(app_state.get("chats") or [], start=1) + if key in (str(index), chat["chat_id"]) + ), + None, + ) + if chat is None: + sys.exit(f"Conversation {key} not found; run `chats` to see the local conversations") + return chat + + +def open_chat(args): + application_id = get_application_id(args) + state = load_state() + chat = new_chat(state.setdefault(application_id, {}), args.title or "") + save_state(state) + print_json({"application_id": application_id, "chat_id": chat["chat_id"], "title": chat["title"]}) + + +def chats(args): + application_id = get_application_id(args) + app_state = load_state().get(application_id, {}) + print_json({"application_id": application_id, "current": app_state.get("current"), "chats": list_chats(app_state)}) + + +def switch_chat(args): + application_id = get_application_id(args) + state = load_state() + app_state = state.get(application_id, {}) + target = find_chat(app_state, args.chat) + app_state["current"] = target["chat_id"] + save_state(state) + print_json({"application_id": application_id, "chat_id": target["chat_id"], "title": target["title"]}) + + +def rename_chat(args): + application_id = get_application_id(args) + state = load_state() + target = find_chat(state.get(application_id, {}), args.chat) + target["title"] = args.title + save_state(state) + print_json({"application_id": application_id, "chat_id": target["chat_id"], "title": target["title"]}) + + +def read_stream(response): + """读取 SSE:TEXT/HTML 分片按顺序拼成回答,FAILURE 收集为错误,直到 [DONE]""" + answer, errors = [], [] + for raw_line in response: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + break + try: + frame = json.loads(payload) + except json.JSONDecodeError: + continue + for block in frame.get("content") or []: + if block.get("type") in ("TEXT", "HTML"): + answer.append(block.get("content") or "") + elif block.get("type") == "FAILURE": + errors.append(block.get("content") or "") + return "".join(answer), errors + + +def chat(args): + base_url, api_key = get_config() + application_id = get_application_id(args) + state = load_state() + app_state = state.setdefault(application_id, {}) + current = next((c for c in app_state.get("chats") or [] if c["chat_id"] == app_state.get("current")), None) + if current is None: + current = new_chat(app_state) + if not current["title"]: + current["title"] = args.message[:TITLE_LENGTH] + save_state(state) + chat_id = current["chat_id"] + payload = {"message": {"content": args.message}, "stream": True, "form_data": json.loads(args.form_data)} + request = urllib.request.Request( + f"{base_url}/api/v3/application/{application_id}/chat/{chat_id}/chat_message", + data=json.dumps(payload).encode("utf-8"), + # 不带 Accept: text/event-stream,DRF 内容协商只支持 JSON 渲染器,会拒绝该请求 + headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, + method="POST", + ) + with open_url(request, base_url) as response: + if "text/event-stream" not in response.headers.get("Content-Type", ""): + # 校验失败等情况在开始流式输出前以 JSON 返回 + data = json.loads(response.read().decode("utf-8")) + sys.exit(f"MaxKB error {data.get('code')}: {data.get('message')}") + answer, errors = read_stream(response) + result = {"application_id": application_id, "chat_id": chat_id, "answer": answer} + if errors: + result["errors"] = errors + print_json(result) + if errors: + sys.exit(1) + + +def download(args): + base_url, api_key = get_config() + match = FILE_PATTERN.search(args.file) + file_id = match.group(1) if match else args.file + request = urllib.request.Request(f"{base_url}/oss/file/{file_id}", headers={"Cookie": f"mk_file_auth={api_key}"}) + with open_url(request, base_url) as response: + disposition = response.headers.get("Content-Disposition", "") + name_match = re.search(r"filename=\"?([^\";]+)\"?", disposition) + file_name = os.path.basename(urllib.parse.unquote(name_match.group(1))) if name_match else file_id + os.makedirs(args.output_dir, exist_ok=True) + path = os.path.join(args.output_dir, file_name) + with open(path, "wb") as f: + f.write(response.read()) + print_json({"file_id": file_id, "path": os.path.abspath(path)}) + + +def main(): + parser = argparse.ArgumentParser(description="MaxKB application skill") + subparsers = parser.add_subparsers(dest="command", required=True) + + application_help = "Application to use; defaults to MAXKB_APPLICATION_ID" + + open_parser = subparsers.add_parser("open", help="Start a new conversation and make it current") + open_parser.add_argument("--title", help="Conversation title; defaults to the first message") + open_parser.add_argument("--application-id", help=application_help) + open_parser.set_defaults(func=open_chat) + + chats_parser = subparsers.add_parser("chats", help="List local conversations") + chats_parser.add_argument("--application-id", help=application_help) + chats_parser.set_defaults(func=chats) + + switch_parser = subparsers.add_parser("switch", help="Make a local conversation current") + switch_parser.add_argument("chat", help="Index from `chats`, or a chat_id") + switch_parser.add_argument("--application-id", help=application_help) + switch_parser.set_defaults(func=switch_chat) + + rename_parser = subparsers.add_parser("rename", help="Change the title of a local conversation") + rename_parser.add_argument("chat", help="Index from `chats`, or a chat_id") + rename_parser.add_argument("title", help="New title") + rename_parser.add_argument("--application-id", help=application_help) + rename_parser.set_defaults(func=rename_chat) + + chat_parser = subparsers.add_parser("chat", help="Send a message in the current conversation") + chat_parser.add_argument("message", help="Question or instruction for the application") + chat_parser.add_argument("--application-id", help=application_help) + chat_parser.add_argument("--form-data", default="{}", help="JSON object of application input fields") + chat_parser.set_defaults(func=chat) + + download_parser = subparsers.add_parser("download", help="Download a file returned by the application") + download_parser.add_argument("file", help="File ID or file link (containing oss/file/<id>)") + download_parser.add_argument("--output-dir", default=".", help="Directory to save the file in") + download_parser.set_defaults(func=download) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/apps/application/urls.py b/apps/application/urls.py index 3269abae289..5e77479d026 100644 --- a/apps/application/urls.py +++ b/apps/application/urls.py @@ -32,6 +32,7 @@ path('workspace/<str:workspace_id>/application/<str:application_id>/application_key/<int:current_page>/<int:page_size>', views.ApplicationKey.Page.as_view()), path('workspace/<str:workspace_id>/application/<str:application_id>/enable_api_key', views.ApplicationApiKeyEnable.as_view()), path('workspace/<str:workspace_id>/application/<str:application_id>/export', views.ApplicationAPI.Export.as_view()), + path('workspace/<str:workspace_id>/application/<str:application_id>/export_skills', views.ApplicationAPI.ExportSkills.as_view()), path('workspace/<str:workspace_id>/application/<str:application_id>/application_version', views.ApplicationVersionView.as_view()), path('workspace/<str:workspace_id>/application/<str:application_id>/access_token', views.AccessToken.as_view()), path('workspace/<str:workspace_id>/application/<str:application_id>/add_knowledge', views.ApplicationChatRecordAddKnowledge.as_view()), diff --git a/apps/application/views/application.py b/apps/application/views/application.py index a516e0f1e63..9bb7c3b3bde 100644 --- a/apps/application/views/application.py +++ b/apps/application/views/application.py @@ -20,6 +20,7 @@ ApplicationQueryAPI, ApplicationImportAPI, ApplicationExportAPI, + ApplicationExportSkillsAPI, ApplicationOperateAPI, ApplicationEditAPI, TextToSpeechAPI, @@ -205,6 +206,39 @@ def get(self, request: Request, workspace_id: str, application_id: str): data={"application_id": application_id, "workspace_id": workspace_id, "user_id": request.user.id} ).export() + class ExportSkills(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=["GET"], + description=_("Export application as skills"), + summary=_("Export application as skills"), + operation_id=_("Export application as skills"), # type: ignore + parameters=ApplicationExportSkillsAPI.get_parameters(), + request=None, + responses=ApplicationExportSkillsAPI.get_response(), + tags=[_("Application")], # type: ignore + ) + @has_permissions( + PermissionConstants.APPLICATION_EXPORT.get_workspace_application_permission(), + PermissionConstants.APPLICATION_EXPORT.get_workspace_permission_workspace_manage_role(), + ViewPermission( + [RoleConstants.USER.get_workspace_role()], + [PermissionConstants.APPLICATION.get_workspace_application_permission()], + compare=CompareConstants.AND, + ), + RoleConstants.WORKSPACE_MANAGE.get_workspace_role(), + ) + @log( + menu="Application", + operate="Export application as skills", + get_operation_object=lambda r, k: get_application_operation_object(k.get("application_id")), + ) + def get(self, request: Request, workspace_id: str, application_id: str): + return ApplicationOperateSerializer( + data={"application_id": application_id, "workspace_id": workspace_id, "user_id": request.user.id} + ).export_skills(request.query_params.get("base_url") or request.build_absolute_uri("/")) + class Operate(APIView): authentication_classes = [TokenAuth] diff --git a/apps/locales/en_US/LC_MESSAGES/django.po b/apps/locales/en_US/LC_MESSAGES/django.po index 2c0f009778b..d378c1cb467 100644 --- a/apps/locales/en_US/LC_MESSAGES/django.po +++ b/apps/locales/en_US/LC_MESSAGES/django.po @@ -1337,6 +1337,12 @@ msgstr "" msgid "Export application" msgstr "" +msgid "Export application as skills" +msgstr "" + +msgid "Invalid MaxKB address" +msgstr "" + #: apps/application/views/application.py:140 #: apps/application/views/application.py:141 #: apps/application/views/application.py:142 diff --git a/apps/locales/zh_CN/LC_MESSAGES/django.po b/apps/locales/zh_CN/LC_MESSAGES/django.po index 90d4890ffcb..ef331004395 100644 --- a/apps/locales/zh_CN/LC_MESSAGES/django.po +++ b/apps/locales/zh_CN/LC_MESSAGES/django.po @@ -1344,6 +1344,12 @@ msgstr "导入智能体" msgid "Export application" msgstr "导出智能体" +msgid "Export application as skills" +msgstr "导出为 Skills" + +msgid "Invalid MaxKB address" +msgstr "MaxKB 访问地址无效" + #: apps/application/views/application.py:140 #: apps/application/views/application.py:141 #: apps/application/views/application.py:142 diff --git a/apps/locales/zh_Hant/LC_MESSAGES/django.po b/apps/locales/zh_Hant/LC_MESSAGES/django.po index 9aeff63652a..2a57279147e 100644 --- a/apps/locales/zh_Hant/LC_MESSAGES/django.po +++ b/apps/locales/zh_Hant/LC_MESSAGES/django.po @@ -1344,6 +1344,12 @@ msgstr "導入智能體" msgid "Export application" msgstr "導出智能體" +msgid "Export application as skills" +msgstr "導出為 Skills" + +msgid "Invalid MaxKB address" +msgstr "MaxKB 訪問地址無效" + #: apps/application/views/application.py:140 #: apps/application/views/application.py:141 #: apps/application/views/application.py:142 diff --git a/apps/oss/serializers/file.py b/apps/oss/serializers/file.py index adf874b0767..6e5bbcd9be2 100644 --- a/apps/oss/serializers/file.py +++ b/apps/oss/serializers/file.py @@ -6,7 +6,7 @@ from django.db.models.functions import Cast from application.models import Application, ApplicationAccessToken, ChatShareLink, Chat -from common.auth.common import parse_token, SystemToken +from common.auth.common import parse_token, SystemToken, ChatToken from common.auth.constants.chat_permission_constants import ChatPermissionConstants from common.auth.constants.operate_constants import Operate from common.auth.handle.impl.user_token import get_auth @@ -28,7 +28,7 @@ from knowledge.services import validate_knowledge_file_size from maxkb.const import CONFIG from rest_framework import serializers -from system_manage.models import WorkspaceUserResourcePermission +from system_manage.models import ChatUserApiKey, WorkspaceUserResourcePermission from system_manage.models.resource_mapping import ResourceMapping, ResourceType from tools.serializers.tool import UploadedFileField from users.models import User @@ -193,6 +193,13 @@ def parse_file_token(mk_file_auth): return SystemToken(str(system_api_key.user_id), AuthenticationType.SYSTEM_USER) else: _deny() + elif mk_file_auth.startswith("chat-"): + # 对话用户 API Key(如导出的 Skills 下载对话文件),按该对话用户鉴权 + chat_user_api_key = QuerySet(ChatUserApiKey).filter(secret_key=mk_file_auth, is_active=True).first() + chat_user = chat_user_api_key.user if chat_user_api_key else None + if chat_user is None or not chat_user.is_active: + _deny() + return ChatToken(str(chat_user.id), AuthenticationType.CHAT_USER, chat_user.source) else: try: return parse_token(token=mk_file_auth) diff --git a/ui/src/api/API_README.md b/ui/src/api/API_README.md index 93bc09ca4f7..ab41a6e2efb 100644 --- a/ui/src/api/API_README.md +++ b/ui/src/api/API_README.md @@ -395,8 +395,11 @@ API 对象和工作空间上下文,作为该抽屉的范围选择例外。Syst `putApplicationPortalDown(applicationId)` 分别向各自范围的 `application/<applicationId>/portal_up`、 `application/<applicationId>/portal_down` 发送 PUT 请求(上下架接口与通用编辑接口分离),返回 `ApplicationDetail`。 上架仅允许已发布(`is_publish`)的智能体;上下架分别受门户上架/下架权限控制。 - `exportApplicationSkills(applicationId, applicationName)` 仍为本地 mock,返回 `{ application_id, name }`, - 不发送请求或生成 Skills 文件,真实下载接口待接入。 + `exportApplicationSkills(applicationId, applicationName, baseUrl?)` 通过 `getExportFile` GET 各自范围的 + `application/<applicationId>/export_skills` 下载 Skills 压缩包,文件名优先取响应头,缺省为 `<applicationName>.zip`。 + 可选 `baseUrl` 以查询参数 `base_url` 传递 MaxKB 访问地址(协议、主机、端口,可带路径前缀),后端拼接对话路径后写入技能配置; + 缺省按请求地址生成,地址无效时返回 400。 + Workspace 后台已提供此接口;System 路径按资源管理前缀接入,当前仓库无对应后台路由,需部署环境联调确认。 ### 智能体概览 diff --git a/ui/src/api/admin/system/resource-management/application/application.ts b/ui/src/api/admin/system/resource-management/application/application.ts index d43e254fca5..b006359c4d0 100644 --- a/ui/src/api/admin/system/resource-management/application/application.ts +++ b/ui/src/api/admin/system/resource-management/application/application.ts @@ -31,9 +31,11 @@ const exportApplication = (applicationId: string, applicationName: string) => { return getExportFile(`${applicationName}.mk`, `${prefix}/${applicationId}/export`) } -/** 模拟 System 资源管理智能体 Skills 导出;TODO:接入真实下载接口。 */ -const exportApplicationSkills = (applicationId: string, applicationName: string) => - Promise.resolve({ application_id: applicationId, name: applicationName }) +/** 导出系统资源管理智能体为 Skills 压缩包;文件名优先使用后端返回的技能名称。 */ +// TODO:当前仓库未提供 System 路由,需在部署环境确认 export_skills 路径。 +const exportApplicationSkills = (applicationId: string, applicationName: string, baseUrl?: string) => { + return getExportFile(`${applicationName}.zip`, `${prefix}/${applicationId}/export_skills`, baseUrl ? { base_url: baseUrl } : undefined) +} /** System 资源管理智能体上架到门户(仅已发布可上架)。 */ const putApplicationPortalUp = (applicationId: string) => put<Record<string, never>, ApplicationDetail>(`${prefix}/${applicationId}/portal_up`) diff --git a/ui/src/api/admin/workspace/application/application.ts b/ui/src/api/admin/workspace/application/application.ts index b587879af46..c9e3979f699 100644 --- a/ui/src/api/admin/workspace/application/application.ts +++ b/ui/src/api/admin/workspace/application/application.ts @@ -61,9 +61,10 @@ const exportApplication = (applicationId: string, applicationName: string) => { return getExportFile(`${applicationName}.mk`, `${getPrefix()}/${applicationId}/export`) } -/** 模拟导出工作空间智能体为 Skills;TODO:接入真实下载接口。 */ -const exportApplicationSkills = (applicationId: string, applicationName: string) => - Promise.resolve({ application_id: applicationId, name: applicationName }) +/** 导出工作空间智能体为 Skills 压缩包;baseUrl 为写入技能的 MaxKB 访问地址,缺省由后端按请求地址生成。 */ +const exportApplicationSkills = (applicationId: string, applicationName: string, baseUrl?: string) => { + return getExportFile(`${applicationName}.zip`, `${getPrefix()}/${applicationId}/export_skills`, baseUrl ? { base_url: baseUrl } : undefined) +} /** 工作空间智能体上架到门户(仅已发布可上架)。 */ const putApplicationPortalUp = (applicationId: string) => put<Record<string, never>, ApplicationDetail>(`${getPrefix()}/${applicationId}/portal_up`) diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index aa5f1b7adf1..0516c74f22c 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -92,8 +92,8 @@ - 智能体的 Workspace 卡片与 System 资源列表复用门户上下架和导出 Action;门户入口按 `is_portal` 显示“上架到门户/从门户下架”,通过当前范围 API 保存,成功后按响应更新列表局部状态,失败保留原状态。 未上架时仅 `is_publish` 为 true 才显示并允许上架;已上架时保留下架入口,下架不修改发布状态。 - 导出使用二级菜单,包含“导出为 Skills”和“导出智能体”;Skills 当前调用 mock 接口并明确提示 - 尚未生成文件,智能体沿用现有文件导出。 + 导出使用二级菜单,包含“导出为 Skills”和“导出智能体”;Skills 下载压缩包,智能体沿用现有文件导出。 + Skills 默认以当前页面 `origin` 作为访问地址;取不到或后端返回 400 时弹窗让用户填写后再导出,取消则结束。 商店列表由页面加载,卡片和版本更新按钮不重复查询。 - 工具头像由 `MkEditAvatar` 暂存文件,校验后先上传再保存;上传成功后清理暂存文件,避免保存失败后重复上传。 - Python 生成入口由 `CodeGenerate` 选择 Tool/Model API,复用 `GenerateContent`;全屏替换只修改草稿,确认后回写。 diff --git a/ui/src/views/application/application-card/action-dropdown/ExportApplicationAction.vue b/ui/src/views/application/application-card/action-dropdown/ExportApplicationAction.vue index 7e339013c67..9e10ddf064d 100644 --- a/ui/src/views/application/application-card/action-dropdown/ExportApplicationAction.vue +++ b/ui/src/views/application/application-card/action-dropdown/ExportApplicationAction.vue @@ -1,9 +1,10 @@ <script setup lang="ts"> import { computed, useTemplateRef } from 'vue' +import { ElMessageBox } from 'element-plus' import type ApplicationApi from '@/api/admin/workspace/application/application' import type SystemResourceApplicationApi from '@/api/admin/system/resource-management/application/application' import type { ApplicationDetail, ExportError } from '@/api/types' -import { MsgError, MsgSuccess } from '@/utils/message' +import { MsgError } from '@/utils/message' defineOptions({ name: 'ExportApplicationAction' }) @@ -15,18 +16,45 @@ const loading = defineModel<boolean>('loading', { default: false }) const exportTriggerRef = useTemplateRef<HTMLDivElement>('exportTriggerRef') const exportMenuContainer = computed(() => exportTriggerRef.value?.closest<HTMLElement>('.el-dropdown__popper')) +/** 让用户填写写入 Skills 的 MaxKB 访问地址。 */ +function promptSkillsBaseUrl(baseUrl: string) { + return ElMessageBox.prompt('请填写 Agent 访问 MaxKB 使用的地址,例如 http://192.168.1.10:8080', '填写访问地址', { + inputValue: baseUrl, + inputPattern: /^https?:\/\/\S+$/, + inputErrorMessage: '请填写以 http:// 或 https:// 开头的地址', + confirmButtonText: '导出', + cancelButtonText: '取消', + }).then(({ value }) => value.trim()) +} + +/** 按访问地址导出 Skills;后端判定地址无效(400)时让用户重新填写后再导出。 */ +function exportSkills(baseUrl: string): Promise<unknown> { + return props.api.exportApplicationSkills(props.application.id, props.application.name, baseUrl).catch((error: ExportError) => { + const status = error.response?.status + if (status === 400) return promptSkillsBaseUrl(baseUrl).then(exportSkills) + if (status && status !== 403) { + return error.response.data.text().then((response: string) => { + MsgError(`导出失败:${JSON.parse(response).message}`) + }) + } + }) +} + function handleExportSkills() { if (loading.value) return loading.value = true - return props.api - .exportApplicationSkills(props.application.id, props.application.name) - .then(() => { - MsgSuccess('Skills 导出已模拟完成,暂不生成文件') - }) - .catch(() => {}) - .finally(() => { - loading.value = false - }) + // 默认使用当前页面地址,取不到时先让用户填写 + const origin = window.location.origin + const baseUrl = origin && origin !== 'null' ? Promise.resolve(origin) : promptSkillsBaseUrl('') + return ( + baseUrl + .then(exportSkills) + // 用户取消填写时静默结束 + .catch(() => {}) + .finally(() => { + loading.value = false + }) + ) } function handleExportApplication() { @@ -60,7 +88,7 @@ function handleExportApplication() { </div> <template #dropdown> <MkDropdownMenu> - <!-- 模拟导出为 Skills --> + <!-- 导出为 Skills 压缩包 --> <MkDropdownItem :disabled="loading" @click="handleExportSkills">导出为 Skills</MkDropdownItem> <!-- 导出智能体文件 --> <MkDropdownItem :disabled="loading" @click="handleExportApplication">导出智能体</MkDropdownItem>