|
| 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