Skip to content

Commit 1f2fbaf

Browse files
authored
feat: Export Applied Skills (#7581)
1 parent b845c91 commit 1f2fbaf

13 files changed

Lines changed: 635 additions & 42 deletions

File tree

‎apps/application/api/application_api.py‎

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
# coding=utf-8
22
"""
3-
@project: MaxKB
4-
@Author:虎虎
5-
@file: application.py
6-
@date:2025/5/26 16:59
7-
@desc:
3+
@project: MaxKB
4+
@Author:虎虎
5+
@file: application.py
6+
@date:2025/5/26 16:59
7+
@desc:
88
"""
9+
910
from django.utils.translation import gettext_lazy as _
1011
from drf_spectacular.types import OpenApiTypes
1112
from drf_spectacular.utils import OpenApiParameter
1213
from rest_framework import serializers
1314

14-
from application.serializers.application import ApplicationCreateSerializer, ApplicationListResponse, \
15-
ApplicationImportRequest, ApplicationEditSerializer, TextToSpeechRequest, SpeechToTextRequest, PlayDemoTextRequest, \
16-
BatchCleanTimeSerializer
15+
from application.serializers.application import (
16+
ApplicationCreateSerializer,
17+
ApplicationListResponse,
18+
ApplicationImportRequest,
19+
ApplicationEditSerializer,
20+
TextToSpeechRequest,
21+
SpeechToTextRequest,
22+
PlayDemoTextRequest,
23+
BatchCleanTimeSerializer,
24+
)
1725
from common.mixins.api_mixin import APIMixin
1826
from common.result import ResultSerializer, ResultPageSerializer, DefaultResultSerializer
1927
from knowledge.serializers.common import BatchSerializer, BatchMoveSerializer
@@ -46,58 +54,58 @@ def get_parameters():
4654
name="workspace_id",
4755
description="工作空间id",
4856
type=OpenApiTypes.STR,
49-
location='path',
57+
location="path",
5058
required=True,
5159
),
5260
OpenApiParameter(
5361
name="current_page",
5462
description=_("Current page"),
5563
type=OpenApiTypes.INT,
56-
location='path',
64+
location="path",
5765
required=True,
5866
),
5967
OpenApiParameter(
6068
name="page_size",
6169
description=_("Page size"),
6270
type=OpenApiTypes.INT,
63-
location='path',
71+
location="path",
6472
required=True,
6573
),
6674
OpenApiParameter(
6775
name="folder_id",
6876
description=_("folder id"),
6977
type=OpenApiTypes.STR,
70-
location='query',
78+
location="query",
7179
required=False,
7280
),
7381
OpenApiParameter(
7482
name="name",
7583
description=_("Application Name"),
7684
type=OpenApiTypes.STR,
77-
location='query',
85+
location="query",
7886
required=False,
7987
),
8088
OpenApiParameter(
8189
name="desc",
8290
description=_("Application Description"),
8391
type=OpenApiTypes.STR,
84-
location='query',
92+
location="query",
8593
required=False,
8694
),
8795
OpenApiParameter(
8896
name="user_id",
8997
description=_("User ID"),
9098
type=OpenApiTypes.STR,
91-
location='query',
99+
location="query",
92100
required=False,
93101
),
94102
OpenApiParameter(
95103
name="publish_status",
96-
description=_("Publish status") + '(published|unpublished)',
104+
description=_("Publish status") + "(published|unpublished)",
97105
type=OpenApiTypes.STR,
98-
location='query',
106+
location="query",
99107
required=False,
100-
)
108+
),
101109
]
102110

103111
@staticmethod
@@ -117,7 +125,7 @@ def get_parameters():
117125
name="workspace_id",
118126
description="工作空间id",
119127
type=OpenApiTypes.STR,
120-
location='path',
128+
location="path",
121129
required=True,
122130
)
123131
]
@@ -149,16 +157,16 @@ def get_parameters():
149157
name="workspace_id",
150158
description="工作空间id",
151159
type=OpenApiTypes.STR,
152-
location='path',
160+
location="path",
153161
required=True,
154162
),
155163
OpenApiParameter(
156164
name="application_id",
157165
description="应用id",
158166
type=OpenApiTypes.STR,
159-
location='path',
167+
location="path",
160168
required=True,
161-
)
169+
),
162170
]
163171

164172

@@ -170,10 +178,11 @@ def get_parameters():
170178
name="workspace_id",
171179
description="工作空间id",
172180
type=OpenApiTypes.STR,
173-
location='path',
181+
location="path",
174182
required=True,
175183
)
176184
]
185+
177186
@staticmethod
178187
def get_request():
179188
return BatchSerializer
@@ -203,6 +212,25 @@ def get_request():
203212
return ApplicationEditSerializer
204213

205214

215+
class ApplicationExportSkillsAPI(APIMixin):
216+
@staticmethod
217+
def get_parameters():
218+
return [
219+
*ApplicationOperateAPI.get_parameters(),
220+
OpenApiParameter(
221+
name="base_url",
222+
description="MaxKB 访问地址(协议、主机、端口,可带路径前缀),缺省使用当前请求地址",
223+
type=OpenApiTypes.STR,
224+
location="query",
225+
required=False,
226+
),
227+
]
228+
229+
@staticmethod
230+
def get_response():
231+
return DefaultResultSerializer
232+
233+
206234
class TextToSpeechAPI(APIMixin):
207235
@staticmethod
208236
def get_parameters():

‎apps/application/serializers/application.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,18 @@ def export(self, with_valid=True):
12331233
except Exception as e:
12341234
return result.error(str(e), response_status=status.HTTP_500_INTERNAL_SERVER_ERROR)
12351235

1236+
def export_skills(self, site_url, with_valid=True):
1237+
if with_valid:
1238+
self.is_valid()
1239+
from application.serializers.application_skill import build_application_skill_zip, get_chat_base_url
1240+
1241+
base_url = get_chat_base_url(site_url)
1242+
application = QuerySet(Application).filter(id=self.data.get("application_id")).first()
1243+
skill_name, content = build_application_skill_zip(application, base_url)
1244+
response = HttpResponse(content_type="application/zip", content=content)
1245+
response["Content-Disposition"] = f'attachment; filename="{skill_name}.zip"'
1246+
return response
1247+
12361248
@staticmethod
12371249
def to_tool_dict(tool, tool_workflow_dict):
12381250
if tool.tool_type == ToolType.WORKFLOW:
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# coding=utf-8
2+
"""
3+
@project: MaxKB
4+
@file: application_skill.py
5+
@desc: 将应用导出为 Skills 压缩包
6+
"""
7+
8+
import io
9+
import json
10+
import os
11+
import re
12+
import zipfile
13+
from urllib.parse import urlsplit
14+
15+
from django.utils.translation import gettext_lazy as _
16+
from rest_framework import status
17+
18+
from common.exception.app_exception import AppApiException
19+
from maxkb.conf import PROJECT_DIR
20+
from maxkb.const import CONFIG
21+
22+
SKILL_SCRIPT_PATH = os.path.join(PROJECT_DIR, "apps", "application", "template", "skill", "skill_script.py")
23+
24+
25+
class InvalidSkillBaseUrl(AppApiException):
26+
# 以 400 返回,前端据此让用户手动填写访问地址
27+
status_code = status.HTTP_400_BAD_REQUEST
28+
29+
def __init__(self):
30+
super().__init__(status.HTTP_400_BAD_REQUEST, _("Invalid MaxKB address"))
31+
32+
33+
def get_chat_base_url(site_url):
34+
"""校验 MaxKB 站点地址(协议 + 主机 + 端口,可带路径前缀)并拼上对话路径。
35+
地址会写入 .env,必须拒绝换行等字符,避免注入额外配置"""
36+
site_url = (site_url or "").strip()
37+
if not site_url or re.search(r"\s", site_url):
38+
raise InvalidSkillBaseUrl()
39+
try:
40+
parts = urlsplit(site_url)
41+
parts.port # 非法端口在此抛出 ValueError
42+
except ValueError:
43+
raise InvalidSkillBaseUrl()
44+
if parts.scheme not in ("http", "https") or not parts.hostname or parts.query or parts.fragment:
45+
raise InvalidSkillBaseUrl()
46+
return f"{site_url.rstrip('/')}{CONFIG.get_chat_path()}"
47+
48+
49+
def get_skill_name(application):
50+
# Skills 规范要求 name 仅含小写字母、数字和连字符,中文名称回退为应用 ID
51+
name = re.sub(r"[^a-z0-9]+", "-", (application.name or "").lower()).strip("-")
52+
return (name or f"application-{str(application.id)[:8]}")[:64]
53+
54+
55+
def get_user_input_fields(application):
56+
nodes = (application.work_flow or {}).get("nodes") or []
57+
base_node = next((n for n in nodes if n.get("type") == "base-node"), None)
58+
if base_node is None:
59+
return []
60+
return (base_node.get("properties") or {}).get("user_input_field_list") or []
61+
62+
63+
def build_skill_md(application, skill_name):
64+
description = " ".join((application.desc or application.name or "").split())
65+
description = f'{description} Use this skill to ask the MaxKB application "{application.name}".'[:1024]
66+
lines = [
67+
"---",
68+
f"name: {skill_name}",
69+
# JSON 字符串即合法的 YAML 双引号字符串,避免描述中的冒号等字符破坏 frontmatter
70+
f"description: {json.dumps(description, ensure_ascii=False)}",
71+
"---",
72+
"",
73+
f"# {application.name}",
74+
"",
75+
]
76+
if application.desc:
77+
lines += [application.desc, ""]
78+
lines += [
79+
"## Commands",
80+
"",
81+
"The script keeps a local list of conversations and a current conversation in "
82+
"`.maxkb_state.json` in this skill's directory, per application. "
83+
"Every command accepts `--application-id <id>` (defaults to `MAXKB_APPLICATION_ID`).",
84+
"",
85+
"### chat",
86+
"",
87+
"Send one message in the current conversation. If there is no current conversation, a new one is "
88+
"started and becomes current.",
89+
"",
90+
"```bash",
91+
f"python {skill_name}/scripts/maxkb.py chat \"<message>\" [--form-data '<json>']",
92+
"```",
93+
"",
94+
"- `--form-data`: JSON object of application input fields",
95+
"",
96+
"Prints JSON `{application_id, chat_id, answer}`. On application failure it also has `errors` "
97+
"and exits with code 1.",
98+
"",
99+
"### open",
100+
"",
101+
"Start a new conversation and make it current.",
102+
"",
103+
"```bash",
104+
f'python {skill_name}/scripts/maxkb.py open [--title "<title>"]',
105+
"```",
106+
"",
107+
"- `--title`: conversation title; defaults to the first message sent in it",
108+
"",
109+
"Prints JSON `{application_id, chat_id, title}`.",
110+
"",
111+
"### chats",
112+
"",
113+
"List the local conversations, oldest first.",
114+
"",
115+
"```bash",
116+
f"python {skill_name}/scripts/maxkb.py chats",
117+
"```",
118+
"",
119+
"Prints JSON `{application_id, current, chats: [{index, chat_id, title, created_at, current}]}`. "
120+
"`title` is the title given to `open` or `rename`, otherwise the first message of the conversation.",
121+
"",
122+
"### switch",
123+
"",
124+
"Make a local conversation current.",
125+
"",
126+
"```bash",
127+
f"python {skill_name}/scripts/maxkb.py switch <index or chat_id>",
128+
"```",
129+
"",
130+
"Prints JSON `{application_id, chat_id, title}`.",
131+
"",
132+
"### rename",
133+
"",
134+
"Change the title of a local conversation.",
135+
"",
136+
"```bash",
137+
f'python {skill_name}/scripts/maxkb.py rename <index or chat_id> "<title>"',
138+
"```",
139+
"",
140+
"Prints JSON `{application_id, chat_id, title}`.",
141+
"",
142+
"### download",
143+
"",
144+
"Download a MaxKB file.",
145+
"",
146+
"```bash",
147+
f"python {skill_name}/scripts/maxkb.py download <file_id or link> [--output-dir <dir>]",
148+
"```",
149+
"",
150+
"- `<file_id or link>`: a file ID, or a link containing `oss/file/<file_id>` (e.g. `./oss/file/<id>`)",
151+
"- `--output-dir`: defaults to the current directory",
152+
"",
153+
"Prints JSON `{file_id, path}`.",
154+
"",
155+
]
156+
fields = get_user_input_fields(application)
157+
if fields:
158+
lines += [
159+
"## Input fields",
160+
"",
161+
"Pass these as a JSON object with `--form-data`:",
162+
"",
163+
]
164+
for field in fields:
165+
label = field.get("label")
166+
if isinstance(label, dict):
167+
label = label.get("label")
168+
required = "required" if field.get("required") else "optional"
169+
lines.append(f"- `{field.get('field')}` ({required}): {label or ''}".rstrip(": "))
170+
lines.append("")
171+
lines += [
172+
"## Configuration",
173+
"",
174+
"Set `MAXKB_API_KEY` in `.env` to the chat user's API key (starting with `chat-`, created in the chat "
175+
"page's user settings). The chat user must have access to this application. "
176+
"Adjust `MAXKB_BASE_URL` if the MaxKB chat page is reached through another address.",
177+
"",
178+
]
179+
return "\n".join(lines)
180+
181+
182+
def build_env(application, base_url):
183+
return "\n".join(
184+
[
185+
f"MAXKB_BASE_URL={base_url}",
186+
f"MAXKB_APPLICATION_ID={application.id}",
187+
"MAXKB_API_KEY=",
188+
"",
189+
]
190+
)
191+
192+
193+
def build_application_skill_zip(application, base_url):
194+
"""返回 (技能名称, zip 字节)。base_url 为对话入口地址,如 http://host/chat"""
195+
skill_name = get_skill_name(application)
196+
with open(SKILL_SCRIPT_PATH, encoding="utf-8") as f:
197+
script = f.read()
198+
buffer = io.BytesIO()
199+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
200+
zip_file.writestr(f"{skill_name}/SKILL.md", build_skill_md(application, skill_name))
201+
zip_file.writestr(f"{skill_name}/scripts/maxkb.py", script)
202+
zip_file.writestr(f"{skill_name}/.env", build_env(application, base_url))
203+
return skill_name, buffer.getvalue()

0 commit comments

Comments
 (0)