Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 51 additions & 23 deletions apps/application/api/application_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -117,7 +125,7 @@ def get_parameters():
name="workspace_id",
description="工作空间id",
type=OpenApiTypes.STR,
location='path',
location="path",
required=True,
)
]
Expand Down Expand Up @@ -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,
)
),
]


Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down
12 changes: 12 additions & 0 deletions apps/application/serializers/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
203 changes: 203 additions & 0 deletions apps/application/serializers/application_skill.py
Original file line number Diff line number Diff line change
@@ -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 <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 \"<message>\" [--form-data '<json>']",
"```",
"",
"- `--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>"]',
"```",
"",
"- `--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()
Loading
Loading