[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text eol=lf\n\n*.png -text\n*.jpg -text\n*.ico -text\n*.gif -text\n*.webp -text\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yaml",
    "content": "name: Bug Report\ndescription: 提交错误报告\ntitle: \"Bug: \"\nlabels:\n  - bug\nbody:\n  - type: textarea\n    attributes:\n      label: Describe the bug\n      description: 请简明地表述 bug 是什么。\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Steps to reproduce\n      description: 请描述如何重现这个行为。\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Expected behavior\n      description: 请描述期望的行为。\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Screenshots\n      description: 请尽量详细地提供相关截图，可以是聊天记录、Koishi 日志和服务端 (如 go-cqhttp) 日志等。文本的日志请复制到下方文本框。\n  - type: textarea\n    id: logs\n    attributes:\n      label: Relevant log output\n      description: 请将日志输出复制到此处。这个文本框的内容会自动渲染为代码，因此你不需要添加反引号 (`)。\n      render: shell\n  - type: dropdown\n    id: launcher\n    attributes:\n      label: Launcher\n      description: 启动 Koishi 的方式\n      options:\n        - Koishi Desktop\n        - Koishi Android\n        - Koishi CLI (koishi start)\n        - Containers (Docker, Kubernates, etc)\n        - Manually (node index.js)\n    validations:\n      required: true\n  - type: dropdown\n    id: backend\n    attributes:\n      label: Backend\n      description: 服务类型及登录方式\n      options:\n        - NovelAI (帐号密码)\n        - NovelAI (Token)\n        - NAIFU\n        - Stable Diffusion WebUI (AUTOMATIC1111)\n        - Stable Horde\n        - Others\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Versions\n      description: 请填写相应的版本号。\n      value: |\n        - OS: <!-- e.g. Windows 10 -->\n        - Adapter: <!-- e.g. Discord -->\n        - Node version: <!-- e.g. 18.12.0 -->\n        - Koishi version: <!-- e.g. 4.9.9 -->\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Additional context\n      description: 请描述其他想要补充的信息。\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yaml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Discussions\n    url: https://github.com/koishijs/novelai-bot/discussions\n    about: 相关话题、分享想法、询问问题，尽情地在讨论中灌水！\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yaml",
    "content": "name: Feature Request\ndescription: 提交功能请求\ntitle: \"Feature: \"\nlabels:\n  - feature\nbody:\n  - type: dropdown\n    id: scope\n    attributes:\n      label: Scope\n      description: 该特性适用的后端\n      multiple: true\n      options:\n        - NovelAI\n        - NAIFU\n        - Stable Diffusion WebUI (AUTOMATIC1111)\n        - Stable Horde\n        - Others\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Describe the problem related to the feature request\n      description: 请简要地说明是什么问题导致你想要一个新特性。\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Describe the solution you'd like\n      description: 请说明你希望使用什么样的方法 (比如增加什么功能) 解决上述问题。\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Describe alternatives you've considered\n      description: 除了上述方法以外，你还考虑过哪些其他的实现方式？\n  - type: textarea\n    attributes:\n      label: Additional context\n      description: 请描述其他想要补充的信息。\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build\n\non:\n  push:\n  pull_request:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Check out\n        uses: actions/checkout@v4\n      - name: Setup Node\n        uses: actions/setup-node@v4\n      - name: Install\n        run: yarn\n      - name: Build\n        run: yarn build\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: Stale\n\non:\n  schedule:\n    - cron: 30 7 * * *\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v4\n        with:\n          stale-issue-label: stale\n          stale-issue-message: |\n            This issue is stale because it has been open 15 days with no activity.\n            Remove stale label or comment or this will be closed in 5 days.\n          close-issue-message: |\n            This issue was closed because it has been stalled for 5 days with no activity.\n          days-before-issue-stale: 15\n          days-before-issue-close: 5\n          any-of-labels: awaiting feedback, invalid\n"
  },
  {
    "path": ".gitignore",
    "content": "lib\ndist\ncache\n\nnode_modules\nnpm-debug.log\nyarn-debug.log\nyarn-error.log\ntsconfig.tsbuildinfo\n\n.eslintcache\n.DS_Store\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020-present Shigma & Ninzore\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "build/fetch-horde-models.js",
    "content": "const fsp = require('fs/promises')\nconst https = require('https')\nconst path = require('path')\n\nconst MODELS_URL = 'https://stablehorde.net/api/v2/status/models'\nconst DATA_JSON_PATH = path.join(__dirname, '..', 'data', 'horde-models.json')\n\n;(async () => {\n  const db = await new Promise((resolve, reject) => {\n    https.get(MODELS_URL, res => {\n      let data = ''\n      res.on('data', chunk => data += chunk)\n      res.on('end', () => resolve(JSON.parse(data)))\n    }).on('error', reject)\n  })\n\n  const models = db.map((model) => model.name)\n\n  const json = JSON.stringify(models, null, 2)\n  await fsp.writeFile(DATA_JSON_PATH, json + '\\n')\n})()\n"
  },
  {
    "path": "build/fetch-sd-samplers.js",
    "content": "const fsp = require('fs/promises')\nconst http = require('http')\nconst path = require('path')\n\nconst API_ROOT = process.argv[2] || 'http://localhost:7860'\nconst SAMPLERS_ENDPOINT = '/sdapi/v1/samplers'\nconst DATA_JSON_PATH = path.join(__dirname, '..', 'data', 'sd-samplers.json')\n\n;(async () => {\n  const r = await new Promise((resolve, reject) => {\n    http.get(API_ROOT + SAMPLERS_ENDPOINT, res => {\n      let data = ''\n      res.on('data', chunk => data += chunk)\n      res.on('end', () => resolve(JSON.parse(data)))\n    }).on('error', reject)\n  })\n\n  const samplers = r.reduce((acc, sampler) => {\n    const { name, aliases, options } = sampler\n    acc[aliases[0]] = name\n    return acc\n  }, {})\n\n  const json = JSON.stringify(samplers, null, 2)\n  await fsp.writeFile(DATA_JSON_PATH, json + '\\n')\n})()\n"
  },
  {
    "path": "crowdin.yml",
    "content": "pull_request_title: 'i18n: update translations'\npull_request_labels:\n  - i18n\nfiles:\n  - source: /src/locales/zh-CN.yml\n    translation: /src/locales/%locale%.yml\n"
  },
  {
    "path": "data/default-comfyui-i2i-wf.json",
    "content": "{\n  \"3\": {\n    \"inputs\": {\n      \"seed\": 1,\n      \"steps\": 20,\n      \"cfg\": 8,\n      \"sampler_name\": \"euler\",\n      \"scheduler\": \"normal\",\n      \"denoise\": 0.87,\n      \"model\": [\n        \"14\",\n        0\n      ],\n      \"positive\": [\n        \"6\",\n        0\n      ],\n      \"negative\": [\n        \"7\",\n        0\n      ],\n      \"latent_image\": [\n        \"12\",\n        0\n      ]\n    },\n    \"class_type\": \"KSampler\",\n    \"_meta\": {\n      \"title\": \"KSampler\"\n    }\n  },\n  \"6\": {\n    \"inputs\": {\n      \"text\": \"\",\n      \"clip\": [\n        \"14\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"7\": {\n    \"inputs\": {\n      \"text\": \"\",\n      \"clip\": [\n        \"14\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"8\": {\n    \"inputs\": {\n      \"samples\": [\n        \"3\",\n        0\n      ],\n      \"vae\": [\n        \"14\",\n        2\n      ]\n    },\n    \"class_type\": \"VAEDecode\",\n    \"_meta\": {\n      \"title\": \"VAE Decode\"\n    }\n  },\n  \"9\": {\n    \"inputs\": {\n      \"filename_prefix\": \"ComfyUI\",\n      \"images\": [\n        \"8\",\n        0\n      ]\n    },\n    \"class_type\": \"SaveImage\",\n    \"_meta\": {\n      \"title\": \"Save Image\"\n    }\n  },\n  \"10\": {\n    \"inputs\": {\n      \"image\": \"example.png\",\n      \"upload\": \"image\"\n    },\n    \"class_type\": \"LoadImage\",\n    \"_meta\": {\n      \"title\": \"Load Image\"\n    }\n  },\n  \"12\": {\n    \"inputs\": {\n      \"pixels\": [\n        \"10\",\n        0\n      ],\n      \"vae\": [\n        \"14\",\n        2\n      ]\n    },\n    \"class_type\": \"VAEEncode\",\n    \"_meta\": {\n      \"title\": \"VAE Encode\"\n    }\n  },\n  \"14\": {\n    \"inputs\": {\n      \"ckpt_name\": \"\"\n    },\n    \"class_type\": \"CheckpointLoaderSimple\",\n    \"_meta\": {\n      \"title\": \"Load Checkpoint\"\n    }\n  }\n}"
  },
  {
    "path": "data/default-comfyui-t2i-wf.json",
    "content": "{\n  \"3\": {\n    \"inputs\": {\n      \"seed\": 1,\n      \"steps\": 20,\n      \"cfg\": 8,\n      \"sampler_name\": \"euler\",\n      \"scheduler\": \"normal\",\n      \"denoise\": 0.87,\n      \"model\": [\n        \"14\",\n        0\n      ],\n      \"positive\": [\n        \"6\",\n        0\n      ],\n      \"negative\": [\n        \"7\",\n        0\n      ],\n      \"latent_image\": [\n        \"16\",\n        0\n      ]\n    },\n    \"class_type\": \"KSampler\",\n    \"_meta\": {\n      \"title\": \"KSampler\"\n    }\n  },\n  \"6\": {\n    \"inputs\": {\n      \"text\": \"\",\n      \"clip\": [\n        \"14\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"7\": {\n    \"inputs\": {\n      \"text\": \"\",\n      \"clip\": [\n        \"14\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"8\": {\n    \"inputs\": {\n      \"samples\": [\n        \"3\",\n        0\n      ],\n      \"vae\": [\n        \"14\",\n        2\n      ]\n    },\n    \"class_type\": \"VAEDecode\",\n    \"_meta\": {\n      \"title\": \"VAE Decode\"\n    }\n  },\n  \"9\": {\n    \"inputs\": {\n      \"filename_prefix\": \"ComfyUI\",\n      \"images\": [\n        \"8\",\n        0\n      ]\n    },\n    \"class_type\": \"SaveImage\",\n    \"_meta\": {\n      \"title\": \"Save Image\"\n    }\n  },\n  \"14\": {\n    \"inputs\": {\n      \"ckpt_name\": \"\"\n    },\n    \"class_type\": \"CheckpointLoaderSimple\",\n    \"_meta\": {\n      \"title\": \"Load Checkpoint\"\n    }\n  },\n  \"16\": {\n    \"inputs\": {\n      \"width\": 512,\n      \"height\": 800,\n      \"batch_size\": 1\n    },\n    \"class_type\": \"EmptyLatentImage\",\n    \"_meta\": {\n      \"title\": \"Empty Latent Image\"\n    }\n  }\n}"
  },
  {
    "path": "data/horde-models.json",
    "content": "[\n  \"3DKX\",\n  \"526Mix-Animated\",\n  \"AAM XL\",\n  \"AbsoluteReality\",\n  \"Abyss OrangeMix\",\n  \"AbyssOrangeMix-AfterDark\",\n  \"ACertainThing\",\n  \"AIO Pixel Art\",\n  \"AlbedoBase XL (SDXL)\",\n  \"AMPonyXL\",\n  \"Analog Diffusion\",\n  \"Analog Madness\",\n  \"Animagine XL\",\n  \"Anime Illust Diffusion XL\",\n  \"Anime Pencil Diffusion\",\n  \"Anygen\",\n  \"AnyLoRA\",\n  \"Anything Diffusion\",\n  \"Anything Diffusion Inpainting\",\n  \"Anything v3\",\n  \"Anything v5\",\n  \"App Icon Diffusion\",\n  \"Art Of Mtg\",\n  \"Aurora\",\n  \"A-Zovya RPG Inpainting\",\n  \"Babes\",\n  \"BB95 Furry Mix\",\n  \"BB95 Furry Mix v14\",\n  \"Blank Canvas XL\",\n  \"BPModel\",\n  \"BRA\",\n  \"BweshMix\",\n  \"CamelliaMix 2.5D\",\n  \"Cetus-Mix\",\n  \"Char\",\n  \"CharHelper\",\n  \"Cheese Daddys Landscape Mix\",\n  \"Cheyenne\",\n  \"ChilloutMix\",\n  \"ChromaV5\",\n  \"Classic Animation Diffusion\",\n  \"Colorful\",\n  \"Comic-Diffusion\",\n  \"Counterfeit\",\n  \"CyberRealistic\",\n  \"CyriousMix\",\n  \"Dan Mumford Style\",\n  \"Dark Sushi Mix\",\n  \"Dark Victorian Diffusion\",\n  \"Deliberate\",\n  \"Deliberate 3.0\",\n  \"Deliberate Inpainting\",\n  \"DGSpitzer Art Diffusion\",\n  \"Disco Elysium\",\n  \"Disney Pixar Cartoon Type A\",\n  \"DnD Item\",\n  \"DnD Map Generator\",\n  \"Double Exposure Diffusion\",\n  \"Dreamlike Diffusion\",\n  \"Dreamlike Photoreal\",\n  \"DreamLikeSamKuvshinov\",\n  \"Dreamshaper\",\n  \"DreamShaper Inpainting\",\n  \"DreamShaper XL\",\n  \"DucHaiten\",\n  \"DucHaiten Classic Anime\",\n  \"Dungeons and Diffusion\",\n  \"Dungeons n Waifus\",\n  \"Edge Of Realism\",\n  \"Eimis Anime Diffusion\",\n  \"Elldreth's Lucid Mix\",\n  \"Elysium Anime\",\n  \"Epic Diffusion\",\n  \"Epic Diffusion Inpainting\",\n  \"Ether Real Mix\",\n  \"Experience\",\n  \"ExpMix Line\",\n  \"FaeTastic\",\n  \"Fantasy Card Diffusion\",\n  \"Fluffusion\",\n  \"Funko Diffusion\",\n  \"Furry Epoch\",\n  \"Fustercluck\",\n  \"Galena Redux\",\n  \"Ghibli Diffusion\",\n  \"GhostMix\",\n  \"GorynichMix\",\n  \"Grapefruit Hentai\",\n  \"Graphic-Art\",\n  \"GTA5 Artwork Diffusion\",\n  \"GuFeng\",\n  \"GuoFeng\",\n  \"HASDX\",\n  \"Hassaku\",\n  \"Hassanblend\",\n  \"Healy's Anime Blend\",\n  \"Henmix Real\",\n  \"Hentai Diffusion\",\n  \"HRL\",\n  \"ICBINP - I Can't Believe It's Not Photography\",\n  \"ICBINP XL\",\n  \"iCoMix\",\n  \"iCoMix Inpainting\",\n  \"Illuminati Diffusion\",\n  \"Inkpunk Diffusion\",\n  \"Jim Eidomode\",\n  \"JoMad Diffusion\",\n  \"Juggernaut XL\",\n  \"JWST Deep Space Diffusion\",\n  \"Kenshi\",\n  \"Laolei New Berry Protogen Mix\",\n  \"Lawlas's yiff mix\",\n  \"Liberty\",\n  \"Lyriel\",\n  \"majicMIX realistic\",\n  \"Mega Merge Diffusion\",\n  \"MeinaMix\",\n  \"Microcritters\",\n  \"Microworlds\",\n  \"Midjourney PaintArt\",\n  \"Mistoon Amethyst\",\n  \"ModernArt Diffusion\",\n  \"Moedel\",\n  \"MoistMix\",\n  \"MoonMix Fantasy\",\n  \"Movie Diffusion\",\n  \"Neurogen\",\n  \"NeverEnding Dream\",\n  \"Nitro Diffusion\",\n  \"OpenJourney Diffusion\",\n  \"Openniji\",\n  \"Papercut Diffusion\",\n  \"Pastel Mix\",\n  \"Perfect World\",\n  \"PFG\",\n  \"Photon\",\n  \"Poison\",\n  \"Pokemon3D\",\n  \"Pony Diffusion XL\",\n  \"PortraitPlus\",\n  \"PPP\",\n  \"Pretty 2.5D\",\n  \"Project Unreal Engine 5\",\n  \"ProtoGen\",\n  \"Protogen Anime\",\n  \"Protogen Infinity\",\n  \"Pulp Vector Art\",\n  \"Quiet Goodnight XL\",\n  \"Ranma Diffusion\",\n  \"RealBiter\",\n  \"Real Dos Mix\",\n  \"Realisian\",\n  \"Realism Engine\",\n  \"Realistic Vision\",\n  \"Realistic Vision Inpainting\",\n  \"Reliberate\",\n  \"Rev Animated\",\n  \"Robo-Diffusion\",\n  \"RPG\",\n  \"Samaritan 3d Cartoon\",\n  \"Sci-Fi Diffusion\",\n  \"SD-Silicon\",\n  \"SDXL 1.0\",\n  \"Seek.art MEGA\",\n  \"Something\",\n  \"Stable Cascade 1.0\",\n  \"stable_diffusion\",\n  \"stable_diffusion_2.1\",\n  \"stable_diffusion_inpainting\",\n  \"SwamPonyXL\",\n  \"SweetBoys 2D\",\n  \"ToonYou\",\n  \"Trinart Characters\",\n  \"Tron Legacy Diffusion\",\n  \"Uhmami\",\n  \"Ultraskin\",\n  \"UMI Olympus\",\n  \"Unstable Diffusers XL\",\n  \"Unstable Ink Dream\",\n  \"URPM\",\n  \"Vector Art\",\n  \"vectorartz\",\n  \"VinteProtogenMix\",\n  \"waifu_diffusion\",\n  \"Western Animation Diffusion\",\n  \"Woop-Woop Photo\",\n  \"Yiffy\",\n  \"Zack3D\",\n  \"Zeipher Female Model\"\n]\n"
  },
  {
    "path": "data/sd-samplers.json",
    "content": "{\n  \"k_dpmpp_2m\": \"DPM++ 2M\",\n  \"k_dpmpp_sde\": \"DPM++ SDE\",\n  \"k_dpmpp_2m_sde\": \"DPM++ 2M SDE\",\n  \"k_dpmpp_2m_sde_heun\": \"DPM++ 2M SDE Heun\",\n  \"k_dpmpp_2s_a\": \"DPM++ 2S a\",\n  \"k_dpmpp_3m_sde\": \"DPM++ 3M SDE\",\n  \"k_euler_a\": \"Euler a\",\n  \"k_euler\": \"Euler\",\n  \"k_lms\": \"LMS\",\n  \"k_heun\": \"Heun\",\n  \"k_dpm_2\": \"DPM2\",\n  \"k_dpm_2_a\": \"DPM2 a\",\n  \"k_dpm_fast\": \"DPM fast\",\n  \"k_dpm_ad\": \"DPM adaptive\",\n  \"restart\": \"Restart\",\n  \"ddim\": \"DDIM\",\n  \"plms\": \"PLMS\",\n  \"unipc\": \"UniPC\",\n  \"k_lcm\": \"LCM\"\n}\n"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "content": "import { defineConfig } from '@cordisjs/vitepress'\n\nexport default defineConfig({\n  lang: 'zh-CN',\n  title: 'NovelAI Bot',\n  description: '基于 NovelAI 的画图机器人',\n\n  head: [\n    ['link', { rel: 'icon', href: 'https://koishi.chat/logo.png' }],\n    ['link', { rel: 'manifest', href: '/manifest.json' }],\n    ['meta', { name: 'theme-color', content: '#5546a3' }],\n  ],\n\n  themeConfig: {\n    nav: [{\n      text: '更多',\n      items: [{\n        text: '关于我们',\n        items: [\n          { text: 'Koishi 官网', link: 'https://koishi.chat' },\n          { text: 'NovelAI.dev', link: 'https://novelai.dev' },\n          { text: '支持作者', link: 'https://afdian.net/a/shigma' },\n        ]\n      }, {\n        text: '友情链接',\n        items: [\n          { text: '法术解析', link: 'https://spell.novelai.dev' },\n          { text: '标签超市', link: 'https://tags.novelai.dev' },\n          { text: '绘世百科', link: 'https://wiki.novelai.dev' },\n          { text: 'AiDraw', link: 'https://guide.novelai.dev' },\n          { text: 'MutsukiBot', link: 'https://nb.novelai.dev' },\n        ],\n      }],\n    }],\n\n    sidebar: [{\n      text: '指南',\n      items: [\n        { text: '介绍', link: '/' },\n        { text: '用法', link: '/usage' },\n        { text: '配置项', link: '/config' },\n        { text: '更多资源', link: '/more' },\n      ],\n    }, {\n      text: 'FAQ',\n      items: [\n        { text: '插件相关', link: '/faq/network' },\n        { text: '适配器相关', link: '/faq/adapter' },\n      ],\n    }, {\n      text: '更多',\n      items: [\n        { text: 'Koishi 官网', link: 'https://koishi.chat' },\n        { text: 'NovelAI.dev', link: 'https://novelai.dev' },\n        { text: '支持作者', link: 'https://afdian.net/a/shigma' },\n      ],\n    }],\n\n    socialLinks: {\n      discord: 'https://discord.com/invite/xfxYwmd284',\n      github: 'https://github.com/koishijs/novelai-bot',\n    },\n\n    footer: {\n      message: `Released under the MIT License.`,\n      copyright: 'Copyright © 2022-present Shigma & Ninzore',\n    },\n\n    editLink: {\n      pattern: 'https://github.com/koishijs/novelai-bot/edit/master/docs/:path',\n    },\n  },\n})\n"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import { defineTheme } from '@koishijs/vitepress/client'\n\nexport default defineTheme({})\n"
  },
  {
    "path": "docs/config.md",
    "content": "# 配置项\n\n## 登录设置\n\n### type\n\n- 类型：`'login' | 'token' | 'naifu' | 'sd-webui' | 'stable-horde'`\n- 默认值：`'token'`\n\n登录方式。`login` 表示使用账号密码登录，`token` 表示使用授权令牌登录。`naifu`、`sd-webui` 和 `stable-horde` 对应着其他类型的后端。\n\n### email\n\n- 类型：`string`\n- 当 `type` 为 `login` 时必填\n\n你的账号邮箱。\n\n### password\n\n- 类型：`string`\n- 当 `type` 为 `login` 时必填\n\n你的账号密码。\n\n### token\n\n- 类型：`string`\n- 当 `type` 为 `token` 时必填\n\n授权令牌。获取方式如下：\n\n1. 在网页中登录你的 NovelAI 账号\n2. 打开控制台 (F12)，并切换到控制台 (Console) 标签页\n3. 输入下面的代码并按下回车运行\n\n```js\nconsole.log(JSON.parse(localStorage.session).auth_token)\n```\n\n4. 输出的字符串就是你的授权令牌\n\n### endpoint\n\n- 类型：`string`\n- 默认值：`'https://api.novelai.net'`\n- 当 `type` 为 `naifu` 或 `sd-webui` 时必填\n\nAPI 服务器地址。如果你搭建了私服，可以将此项设置为你的服务器地址。\n\n### headers\n\n- 类型：`Dict<string>`\n- 默认值：官服的 `Referer` 和 `User-Agent`\n\n要附加的额外请求头。如果你的 `endpoint` 是第三方服务器，你可能需要设置正确的请求头，否则请求可能会被拒绝。\n\n### trustedWorkers\n\n- 类型: `boolean`\n- 默认值: `false`\n- 当 `type` 为 `stable-horde` 时可选\n\n是否只请求可信任工作节点。\n\n### pollInterval\n\n- 类型: `number`\n- 默认值: `1000`\n- 当 `type` 为 `stable-horde` 时可选\n\n轮询进度间隔时长。单位为毫秒。\n\n## 参数设置\n\n### model\n\n- 类型：`'safe' | 'nai' | 'furry'`\n- 默认值：`'nai'`\n\n默认的生成模型。\n\n### sampler\n\n- 类型：`'k_euler_ancestral' | 'k_euler' | 'k_lms' | 'plms' | 'ddim'`\n- 默认值：`'k_euler_ancestral'`\n\n默认的采样器。\n\n### scale\n\n- 类型：`number`\n- 默认值：`11`\n\n默认对输入的服从度。\n\n### textSteps\n\n- 类型：`number`\n- 默认值：`28`\n\n文本生图时默认的迭代步数。\n\n### imageSteps\n\n- 类型：`number`\n- 默认值：`50`\n\n以图生图时默认的迭代步数。\n\n### maxSteps\n\n- 类型：`number`\n- 默认值：`64`\n\n允许的最大迭代步数。\n\n### strength\n\n- 类型：`number`\n- 默认值：`0.7`\n- 取值范围：`(0, 1]`\n\n默认的重绘强度。\n\n### resolution\n\n- 类型：`'portrait' | 'square' | 'landscape' | { width: number, height: number }`\n- 默认值：`'portrait'`\n\n默认生成的图片尺寸。\n\n### maxResolution\n\n- 类型：`number`\n- 默认值：`1024`\n\n允许生成的宽高最大值。\n\n## 输入设置\n\n### basePrompt\n\n- 类型: `string`\n- 默认值: `'masterpiece, best quality'`\n\n所有请求的附加标签。默认值相当于网页版的「Add Quality Tags」功能。\n\n### negativePrompt\n\n- 类型: `string`\n- 默认值:\n  ```text\n  nsfw, lowres, bad anatomy, bad hands, text, error, missing fingers,\n  extra digit, fewer digits, cropped, worst quality, low quality,\n  normal quality, jpeg artifacts, signature, watermark, username, blurry\n  ```\n\n所有请求附加的负面标签。默认值相当于网页版的「Low Quality + Bad Anatomy」排除。\n\n### forbidden\n\n- 类型：`string`\n- 默认值：`''`\n\n违禁词列表。请求中的违禁词将会被自动删除。\n\n违禁词语法与关键词类似，使用逗号隔开英文单词。由于它只用于过滤输入，因此不接受影响因子和要素混合等高级语法。默认情况下，每个违禁词均采用模糊匹配，即只要输入的某个关键词中包含任何一个违禁词作为子串，就会被自动删除。如果要使用精确匹配，可以在词尾加上 `!`。例如 `sex!` 仅会过滤 `sex toys` 而不过滤 `sexy girl`。\n\n默认情况下本插件不设违禁词。对于想要禁用 nsfw 内容的用户，下面的违禁词表可供参考：\n\n```text\nguro, nipple, anal, anus, masturbation, sex!, rape, fuck,\ndick, testis, nude, nake, cum, nudity, virgina, penis, nsfw,\ntopless, ass, bdsm, footjob, handjob, fellatio, deepthroat,\ncum, ejaculation, bukkake, orgasm, pussy, bloody\n```\n\n### placement\n\n- 类型：`'before' | 'after'`\n- 默认值：`'after'`\n\n默认附加标签相对用户输入的摆放位置。\n\n设置为 `before` 意味着默认标签拥有更高的优先级 (如果希望与 NovelAI 官网行为保持一致，推荐这个选项)，而设置为 `after` 将允许用户更高的创作自由度。\n\n在 `before` 模式下，用户仍然可以通过 `-O, --override` 选项手动忽略默认标签；而在 `after` 模式下，用户仍然可以通过将基础标签写在前面的方式手动提高基础标签的优先级。\n\n### translator\n\n- 类型：`boolean`\n- 默认值：`true`\n\n是否启用自动翻译。安装任意 [翻译插件](https://translator.koishi.chat) 后即可自动将用户输入转换为英文。\n\n### latinOnly\n\n- 类型：`boolean`\n- 默认值：`false`\n\n是否只接受英文单词。启用后将对于非拉丁字母的输入进行错误提示。\n\n::: tip\n[自动翻译](#translator) 会转化用户输入，因此不建议与此选项同时启用。\n:::\n\n### maxWords\n\n- 类型：`number`\n- 默认值：`0`\n\n用户输入的最大词数 (设置为 `0` 时无限制)。下面是一些细节设定：\n\n- 这个配置项限制的是词数而不是标签数或字符数 (`girl, pleated skirt` 的词数为 3)\n- 正向标签和反向标签分别计数，每种均不能超出此限制，但总词数可以达到限制的两倍\n- 如果开启了 [自动翻译](#translator)，此限制将作用于翻译后的结果\n- 默认附加的正向和反向标签均不计入此限制\n\n## 高级设置\n\n### output\n\n- 类型：`'minimal' | 'default' | 'verbose'`\n- 默认值：`'default'`\n\n输出方式。`minimal` 表示只发送图片，`default` 表示发送图片和关键信息，`verbose` 表示发送全部信息。\n\n### allowAnlas\n\n- 类型：`boolean | number`\n- 默认值：`true`\n\n是否启用高级功能。高级功能包括：\n\n- 以图生图相关功能\n- `-r, --resolution` 选项中，手动指定具体宽高\n- `-O, --override` 选项\n- `-t, --steps` 选项\n- `-n, --noise` 选项\n- `-N, --strength` 选项\n\n当设置为数字时，表示使用上述功能所需的最低权限等级。\n\n### requestTimeout\n\n- 类型：`number`\n- 默认值：`30000`\n\n当请求超过这个时间时会中止并提示超时。\n\n### maxRetryCount\n\n- 类型：`number`\n- 默认值：`3`\n\n连接失败时最大的重试次数。\n\n<!-- ### recallTimeout\n\n- 类型：`number`\n- 默认值：`0`\n\n图片发送后自动撤回的时间 (设置为 `0` 禁用此功能)。 -->\n\n### maxIterations\n\n- 类型：`number`\n- 默认值：`1`\n\n允许的最大绘制次数。参见 [批量生成](./usage.md#批量生成)。\n\n### maxConcurrency\n\n- 类型：`number`\n- 默认值：`0`\n\n单个频道下的最大并发数量 (设置为 `0` 以禁用此功能)。\n"
  },
  {
    "path": "docs/faq/adapter.md",
    "content": "# 适配器相关\n\n## OneBot\n\n### 消息发送失败，账号可能被风控\n\n如果你刚开始使用 go-cqhttp，建议挂机 3-7 天，即可解除风控。\n\n### 未连接到 go-cqhttp 子进程\n\n请尝试重新下载 gocqhttp 插件。\n\n### 选择登陆方式的按钮存在但点击无效\n\n已知问题：<https://github.com/koishijs/koishi-plugin-gocqhttp/issues/7>\n\n## Discord\n\n### 连接失败：disallowed intent(s)\n\n解决方案：<https://github.com/koishijs/koishi/issues/804>\n\n## Telegram\n\n### 群组消息不接收\n\n解决方案：<https://github.com/koishijs/koishi/issues/513>\n"
  },
  {
    "path": "docs/faq/network.md",
    "content": "# 插件相关\n\n## 功能相关\n\n### 使用这个插件必须花钱吗？\n\n如果你用默认配置，那么是需要的。你也可以选择自己搭建服务器或使用 colab 等，这些方案都是免费的。\n\n## 网络相关\n\n### 请求超时\n\n如果偶尔发生无需在意。如果总是发生请尝试调高 `requestTimeout` 的数值或者配置 `proxyAgent`。\n\n`proxyAgent` 设置的方式为\n\n前往插件配置-全局设置-最下方的请求设置-使用的代理服务器地址-填入代理服务器地址-点击右上角的重载配置。\n\n你要填入的内容为 `socks://ip:port` 例如 `socks://127.0.0.1:7890`。\n\n### 未知错误 400 或 401\n\n请确认 `type` 是否设置为 `token`。\n\n将 `type` 切换成 `login` 后重载，再次切换成 `token` 重载可以解决此问题。\n\n### 未知错误 404\n\n请确认 `endpoint` 是否正确，以及版本是否是最新。\n\n如果你选择的 `type` 是 `sd-webui`，你还需确认你使用的 `sd-webui` 是来自于 `AUTOMATIC1111` 的版本，**且在启动时添加了 `--api` 参数**。\n\n如果部署 sd-webui 与运行 koishi 的不是同一台电脑，且未通过隧道转发本地端口, 那么你还需要添加 `--listen` 参数。\n\n### 未知错误 422\n\n请更新插件版本。如插件版本已是最新，请尝试重置插件配置，并重新填写相应字段解决。\n\n### 未知错误 500\n\n请更新插件版本。\n\n### 未知错误 502\n\n如果你选择的 `type` 是 `sd-webui`，且采用了公网转发的方式提供服务 (即在启动的时候传递了 `--share` 或 `--ngrok` 参数)，请确认你运行 `sd-webui` 的机器的网络情况，及与上述隧道服务的服务器的连通情况。\n\n### 未知错误 503\n\n如果你使用 NovelAI 官网作为后端 (`login` 或 `token`)，请尝试将 `endpoint` 中的 `api` 替换成 `backend-production-svc` 再试试。\n"
  },
  {
    "path": "docs/index.md",
    "content": "# 介绍\n\n基于 [NovelAI](https://novelai.net/) 的画图插件。已实现功能：\n\n- 绘制图片\n- 更改模型、采样器、图片尺寸\n- 高级请求语法\n- 自定义违禁词表\n- 中文关键词自动翻译\n- 发送一段时间后自动撤回\n- 连接到私服 · [SD-WebUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) · [Stable Horde](https://stablehorde.net/)\n- img2img · 图片增强\n\n得益于 Koishi 的插件化机制，只需配合其他插件即可实现更多功能：\n\n- 多平台支持 (QQ、Discord、Telegram、开黑啦等)\n- 速率限制 (限制每个用户每天可以调用的次数和每次调用的间隔)\n- 上下文管理 (限制在哪些群聊中哪些用户可以访问)\n- 多语言支持 (为使用不同语言的用户提供对应的回复)\n\n**所以快去给 [Koishi](https://github.com/koishijs/koishi) 点个 star 吧！**\n\n## 效果展示\n\n以下图片均使用本插件在聊天平台生成：\n\n| ![example](https://cdn-shiki.momobako.com:444/static/portrait/a11ty-f9drh.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/aaepw-4umze.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/ae4bk-32pk7.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/aoy1m-8evrd.webp) |\n|:-:|:-:|:-:|:-:|\n| ![example](https://cdn-shiki.momobako.com:444/static/portrait/ap8ia-2yuco.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/a7k8p-gba0y.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/a31uu-ou34k.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/agxe3-4mwjs.webp) |\n\n## 快速搭建\n\n::: warning\n在此之前，你需要一个**拥有有效付费计划的 NovelAI 账号**，本插件只使用 NovelAI 提供的接口。\n付费计划请自行前往 [NovelAI](https://novelai.net/) 了解。\n:::\n\n给没有使用过 Koishi 的新人提供一份简单的快速搭建指南：\n\n1. 前往[这里](https://koishi.chat/manual/starter/windows.html)下载 Koishi 桌面版\n2. 启动桌面版，你将会看到一个控制台界面\n3. 点击左侧的「插件市场」，搜索「novelai」并点击「安装」\n4. 点击左侧的「插件配置」，选择「novelai」插件，并在以下两种方案中**任选一种**：\n    - 选择登录方式为「账号密码」，并在「email」和「password」中填入邮箱和密码 (推荐)\n    - 选择登录方式为「授权令牌」，并在「token」中填入授权令牌 ([获取方式](./config.md#token))\n5. 点击右上角的「启用」按钮\n6. 现在你已经可以在「沙盒」中使用画图功能了！\n\n如果想进一步在 QQ 中使用，可继续进行下列操作：\n\n1. 准备一个用于搭建机器人的 QQ 号 (等级不要过低，否则可能被风控)\n2. 点击左侧的「插件配置」，选择「onebot」插件，完成以下配置：\n    - 在「selfId」填写你的 QQ 号\n    - 开启「gocqhttp.enable」选项\n3. 点击右上角的「启用」按钮\n4. 使用你填写的 QQ 号进行扫码登录\n5. 现在你可以在 QQ 上中使用画图功能了！\n"
  },
  {
    "path": "docs/more.md",
    "content": "# 更多资源\n\n## 公开机器人\n\n[这里](https://github.com/koishijs/novelai-bot/discussions/75) 收集了基于 NovelAI Bot 搭建的公开机器人。大家可以自行前往这些机器人所在的群组进行游玩和体验。\n\n### QQ 群\n\n- 群号：767973430\n\n### Kook (开黑啦)\n\n- https://kook.top/9MxXht\n\n### Discord\n\n- https://discord.gg/w9uDYbBHnx\n\n### Telegram\n\n- https://t.me/+hhcuca5BUIs5YWM1\n\n\n## 优质作品\n\n[这里](https://github.com/koishijs/novelai-bot/discussions/88) 收集了使用 NovelAI Bot 创作的优质作品。也欢迎大家将自己绘制的图片分享到这里，还有机会被收录到首页的轮播图中。\n\n## 友情链接\n\n[NovelAI.dev](https://novelai.dev) 是一个以技术宅为核心的 AI 绘画爱好者群体。我们围绕 AI 绘图技术开发了更多应用：\n\n- [法术解析](https://spell.novelai.dev/)：从 NovelAI 生成的图片读取内嵌的 prompt\n- [标签超市](https://tags.novelai.dev/)：快速构建 Danbooru 标签组合\n- [绘世百科](https://wiki.novelai.dev/)：收集和整理 AI 绘图相关资料\n\n"
  },
  {
    "path": "docs/public/manifest.json",
    "content": "{\n  \"name\": \"NovelAI Bot\",\n  \"short_name\": \"NovelAI Bot\",\n  \"description\": \"基于 NovelAI 的画图机器人\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#ffffff\",\n  \"theme_color\": \"#5546a3\",\n  \"icons\": [\n    {\n      \"src\": \"https://koishi.chat/logo.png\",\n      \"sizes\": \"384x384\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n"
  },
  {
    "path": "docs/usage.md",
    "content": "# 用法\n\n## 基本用法\n\n### 从文本生成图片 (text2img)\n\n输入「约稿」+ 关键词进行图片绘制。例如：\n\n```text\n约稿 koishi\n```\n\n### 从图片生成图片 (img2img)\n\n输入「约稿」+ 图片 + 关键词 进行图片绘制。例如：\n\n```text\n约稿 [图片] koishi\n```\n\n### 图片增强 (enhance)\n\n图片增强用于优化已经生成的图片。输入「增强」+ 图片 + 关键词 进行图片增强。例如：\n\n```text\n增强 [图片] koishi\n```\n\n### 引用图片回复\n\n考虑到某些平台并不支持在一条消息中同时出现图片和文本，我们也允许通过引用回复的方式触发 img2img 和 enhance 功能。例如：\n\n```text\n> [图片]\n> [引用回复] 约稿/增强\n```\n\n### 多次生成 (iterations)\n\n::: tip\n此功能需要通过配置项 [`maxIterations`](./config.md#maxiterations) 手动开启。\n:::\n\n如果想要以一组输入多次生成图片，可以使用 `-i, --iterations` 参数：\n\n```text\n约稿 -i 10 koishi\n```\n\n### 批量生成 (batch)\n\n::: tip\n此功能需要通过配置项 [`maxIterations`](./config.md#maxiterations) 手动开启。\n:::\n\n如果想要以一组输入批量生成图片，可以使用 `-b, --batch` 参数：\n\n```text\n约稿 -b 10 koishi\n```\n\n### 输出方式 (output)\n\n此插件提供了三种不同的输出方式：`minimal` 表示只发送图片，`default` 表示发送图片和关键信息，`verbose` 表示发送全部信息。你可以使用 `-o, --output` 手动指定输出方式，或通过配置项修改默认的行为。\n\n```text\n约稿 -o minimal koishi\n```\n\n## 关键词 (prompt)\n\n使用关键词描述你想要的图像。关键词需要为英文，多个关键词之间用逗号分隔。每一个关键词也可以由多个单词组成，单词之间可以用空格或下划线分隔。例如：\n\n```text\n约稿 long hair, from_above, 1girl\n```\n\n::: tip\nnovelai-bot 同时兼容 NovelAI 和大部分 stable diffusion webui 的语法。\n:::\n\n### 负面关键词\n\n使用 `-u` 或 `negative prompt:` 以添加负面关键词，避免生成不需要的内容。例如：\n\n```text\n约稿 girl\nnegative prompt: loli\n```\n\n### 影响因子\n\n使用半角方括号 `[]` 包裹关键词以减弱该关键词的权重，使用半角花括号 `{}` 包裹关键词以增强该关键词的权重。例如：\n\n```text\n约稿 [tears], {spread legs}\n```\n\n每一层括号会增强 / 减弱 1.05 倍的权重。也可以通过多次使用括号来进一步增强或减弱关键词的权重。例如：\n\n```text\n约稿 [[tears]], {{{smile}}}\n```\n\n::: tip\n除了影响因子外，关键词的顺序也会对生成结果产生影响。越重要的词应该放到越前面。\n:::\n\n### 要素混合\n\n使用 `|` 分隔多个关键词以混合多个要素。例如：\n\n```text\n约稿 cat | frog\n```\n\n你将得到一只缝合怪 (字面意义上)。\n\n可以进一步在关键词后添加 `:x` 来指定单个关键词的权重，`x` 的取值范围是 `0.1~100`，默认为 1。例如：\n\n```text\n约稿 cat :2 | dog\n```\n\n这时会得到一个更像猫的猫狗。\n\n### 基础关键词\n\nNovelAI Bot 允许用户配置基础的正面和负面关键词。它们会在请求时被添加在结尾。\n\n如果想要手动忽略这些基础关键词，可以使用 `-O, --override` 参数。\n\n## 高级功能\n\n### 更改生成模型 (model)\n\n可以用 `-m` 或 `--model` 切换生成模型，可选值包括：\n\n- `safe`：较安全的图片\n- `nai`：自由度较高的图片 (默认)\n- `furry`：福瑞控特攻 (beta)\n\n```text\n约稿 -m furry koishi\n```\n\n### 设置分辨率 (resolution)\n\n::: warning\n此选项在图片增强时不可用。\n:::\n\n可以用 `-r` 或 `--resolution` 更改图片方向，它包含三种预设：\n\n- `portrait`：768×512 (默认)\n- `square`：640×640\n- `landscape`：512×768\n\n```text\n约稿 -r landscape koishi\n```\n\n除了上述三种预设外，你还可以指定图片的具体长宽：\n\n```text\n约稿 -r 1024x1024 koishi\n```\n\n::: tip\n由于 Stable Diffusion 的限制，输出图片的长宽都必须是 64 的倍数。当你输入的图片长宽不满足此条件时，我们会自动修改为接近此宽高比的合理数值。\n:::\n\n### 切换采样器 (sampler)\n\n可以用 `-s` 或 `--sampler` 设置采样器，可选值包括：\n\n- `k_euler_ancestral` (默认)\n- `k_euler`\n- `k_lms`\n- `plms`\n- `ddim`\n\n即使使用了相同的输入，不同的采样器也会输出不同的内容。目前一般推荐使用 `k_euler_ancestral`，因为其能够提供相对稳定的高质量图片生成 (欢迎在 issue 中讨论各种采样器的区别)。\n\n### 随机种子 (seed)\n\nAI 会使用种子来生成噪音然后进一步生成你需要的图片，每次随机生成时都会有一个唯一的种子。使用 `-x` 或 `--seed` 并传入相同的种子可以让 AI 尝试使用相同的路数来生成图片。\n\n```text\n约稿 -x 1234567890 koishi\n```\n\n::: tip\n注意：在同一模型和后端实现中，保持所有参数一致的情况下，相同的种子会产生同样的图片。取决于其他参数，后端实现和模型，相同的种子不一定生成相同的图片，但一般会带来更多的相似之处。\n:::\n\n### 迭代步数 (steps)\n\n更多的迭代步数**可能**会有更好的生成效果，但是一定会导致生成时间变长。太多的steps也可能适得其反，几乎不会有提高。\n\n一种做法是先使用较少的步数来进行快速生成来检查构图，直到找到喜欢的，然后再使用更多步数来生成最终图像。\n\n默认情况下的迭代步数为 28 (传入图片时为 50)，28 也是不会收费的最高步数。可以使用 `-t` 或 `--steps` 手动控制迭代步数。\n\n```text\n约稿 -t 50 koishi\n```\n\n### 对输入的服从度 (scale)\n\n服从度较低时 AI 有较大的自由发挥空间，服从度较高时 AI 则更倾向于遵守你的输入。但如果太高的话可能会产生反效果 (比如让画面变得难看)。更高的值也需要更多计算。\n\n有时，越低的 scale 会让画面有更柔和，更有笔触感，反之会越高则会增加画面的细节和锐度。\n\n| 服从度 | 行为 |\n| :---: | --- |\n| 2~8   | 会自由地创作，AI 有它自己的想法 |\n| 9~13  | 会有轻微变动，大体上是对的 |\n| 14~18 | 基本遵守输入，偶有变动 |\n| 19+   | 非常专注于输入 |\n\n默认情况下的服从度为 12 (传入图片时为 11)。可以使用 `-c` 或 `--scale` 手动控制服从度。\n\n```text\n约稿 -c 10 koishi\n```\n\n### 强度 (strength)\n\n::: tip\n注意：该参数仅能在 img2img 模式下使用。\n:::\n\nAI 会参考该参数调整图像构成。值越低越接近于原图，越高越接近训练集平均画风。使用 `-N` 或 `--strength` 手动控制强度。\n\n| 使用方式 | 推荐范围 |\n| :---: | --- |\n| 捏人 | 0.3~0.7 |\n| 草图细化   | 0.2 |\n| 细节设计  | 0.2~0.5 |\n| 装饰性图案设计 | 0.2~0.36 |\n| 照片转背景 | 0.3~0.7 |\n| 辅助归纳照片光影 | 0.2~0.4 |\n\n以上取值范围来自微博画师**帕兹定律**的[这条微博](https://share.api.weibo.cn/share/341911942,4824092660994264.html)。\n\n### 噪音 (noise)\n\n::: tip\n注意：该参数仅能在 NovelAI / NAIFU 的 img2img 模式下使用。\n:::\n\n噪音是让 AI 生成细节内容的关键。更多的噪音可以让生成的图片拥有更多细节，但是太高的值会让产生异形，伪影和杂点。\n\n如果你有一张有大片色块的草图，可以调高噪音以产生细节内容，但噪音的取值不宜大于强度。当强度和噪音都为 0 时，生成的图片会和原图几乎没有差别。\n\n使用 `-n` 或 `--noise` 手动控制噪音。\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"koishi-plugin-novelai\",\n  \"description\": \"Generate images by diffusion models\",\n  \"version\": \"1.27.0\",\n  \"main\": \"lib/index.js\",\n  \"typings\": \"lib/index.d.ts\",\n  \"files\": [\n    \"lib\",\n    \"dist\",\n    \"data\"\n  ],\n  \"browser\": {\n    \"image-size\": false,\n    \"libsodium-wrappers\": false\n  },\n  \"author\": \"Ninzore <cillerpro@gmail.com>\",\n  \"contributors\": [\n    \"Shigma <shigma10826@gmail.com>\",\n    {\n      \"name\": \"Maiko Tan\",\n      \"email\": \"maiko.tan.coding@gmail.com\"\n    },\n    \"Ninzore <cillerpro@gmail.com>\"\n  ],\n  \"license\": \"MIT\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/koishijs/koishi-plugin-novelai.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/koishijs/koishi-plugin-novelai/issues\"\n  },\n  \"homepage\": \"https://bot.novelai.dev\",\n  \"scripts\": {\n    \"build\": \"atsc -b\",\n    \"docs:dev\": \"vitepress dev docs --open\",\n    \"docs:build\": \"vitepress build docs\",\n    \"docs:serve\": \"vitepress serve docs\"\n  },\n  \"koishi\": {\n    \"browser\": true,\n    \"service\": {\n      \"optional\": [\n        \"translator\"\n      ]\n    },\n    \"description\": {\n      \"en\": \"Image Generation. Support [NovelAI](https://novelai.net/), Stable Diffusion and more.\",\n      \"zh\": \"画图插件，支持 [NovelAI](https://novelai.net/)、Stable Diffusion 等\",\n      \"zh-TW\": \"畫圖插件，支持 [NovelAI](https://novelai.net/)、Stable Diffusion 等\",\n      \"fr\": \"Génération des images. Fonctionner sous [NovelAI](https://novelai.net/), Stable Diffusion et plus\",\n      \"ja\": \"画像生成。[NovelAI](https://novelai.net/) や Stable Diffusion などに対応する。\"\n    }\n  },\n  \"keywords\": [\n    \"chatbot\",\n    \"koishi\",\n    \"plugin\",\n    \"novelai\",\n    \"ai\",\n    \"paint\",\n    \"image\",\n    \"generate\"\n  ],\n  \"peerDependencies\": {\n    \"koishi\": \"^4.18.7\"\n  },\n  \"devDependencies\": {\n    \"@cordisjs/vitepress\": \"^3.3.2\",\n    \"@koishijs/plugin-help\": \"^2.4.5\",\n    \"@koishijs/translator\": \"^1.1.1\",\n    \"@types/adm-zip\": \"^0.5.7\",\n    \"@types/libsodium-wrappers-sumo\": \"^0.7.8\",\n    \"@types/node\": \"^22.13.8\",\n    \"atsc\": \"^1.2.2\",\n    \"koishi\": \"^4.18.7\",\n    \"sass\": \"^1.85.1\",\n    \"typescript\": \"^5.8.2\",\n    \"vitepress\": \"1.0.0-rc.40\"\n  },\n  \"dependencies\": {\n    \"adm-zip\": \"^0.5.16\",\n    \"image-size\": \"^1.2.0\",\n    \"libsodium-wrappers-sumo\": \"^0.7.15\"\n  }\n}\n"
  },
  {
    "path": "readme.md",
    "content": "# [koishi-plugin-novelai](https://bot.novelai.dev)\n\n[![downloads](https://img.shields.io/npm/dm/koishi-plugin-novelai?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-novelai)\n[![npm](https://img.shields.io/npm/v/koishi-plugin-novelai?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-novelai)\n\n基于 [NovelAI](https://novelai.net/) 的画图插件。已实现功能：\n\n- 绘制图片\n- 更改模型、采样器、图片尺寸\n- 高级请求语法\n- 自定义违禁词表\n- 中文关键词自动翻译\n- 发送一段时间后自动撤回\n- 连接到私服 · [SD-WebUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) · [Stable Horde](https://stablehorde.net/)\n- img2img · 图片增强\n\n得益于 Koishi 的插件化机制，只需配合其他插件即可实现更多功能：\n\n- 多平台支持 (QQ、Discord、Telegram、开黑啦等)\n- 速率限制 (限制每个用户每天可以调用的次数和每次调用的间隔)\n- 上下文管理 (限制在哪些群聊中哪些用户可以访问)\n- 多语言支持 (为使用不同语言的用户提供对应的回复)\n\n**所以所以快去给 [Koishi](https://github.com/koishijs/koishi) 点个 star 吧！**\n\n## 效果展示\n\n以下图片均使用本插件在聊天平台生成：\n\n| ![example](https://cdn-shiki.momobako.com:444/static/portrait/a11ty-f9drh.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/aaepw-4umze.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/ae4bk-32pk7.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/aoy1m-8evrd.webp) |\n|:-:|:-:|:-:|:-:|\n| ![example](https://cdn-shiki.momobako.com:444/static/portrait/ap8ia-2yuco.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/a7k8p-gba0y.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/a31uu-ou34k.webp) | ![example](https://cdn-shiki.momobako.com:444/static/portrait/agxe3-4mwjs.webp) |\n\n## 使用教程\n\n搭建教程、使用方法、参数配置、常见问题请见：<https://bot.novelai.dev>\n\n## License\n\n使用 [MIT](./LICENSE) 许可证发布。\n\n```\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n```\n\n[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fkoishijs%2Fnovelai-bot.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fkoishijs%2Fnovelai-bot?ref=badge_large)\n"
  },
  {
    "path": "src/config.ts",
    "content": "import { Computed, Dict, Schema, Session, Time } from 'koishi'\nimport { Size } from './utils'\n\nconst options: Computed.Options = {\n  userFields: ['authority'],\n}\n\nexport const modelMap = {\n  safe: 'safe-diffusion',\n  nai: 'nai-diffusion',\n  furry: 'nai-diffusion-furry',\n  'nai-v3': 'nai-diffusion-3',\n  'nai-v4-curated-preview': 'nai-diffusion-4-curated-preview',\n  'nai-v4-full': 'nai-diffusion-4-full',\n} as const\n\nexport const orientMap = {\n  landscape: { height: 832, width: 1216 },\n  portrait: { height: 1216, width: 832 },\n  square: { height: 1024, width: 1024 },\n} as const\n\nexport const hordeModels = require('../data/horde-models.json') as string[]\n\nconst ucPreset = [\n  // Replace with the prompt words that come with novelai\n  'nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality',\n  'jpeg artifacts, bad quality, watermark, unfinished, displeasing',\n  'chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]',\n].join(', ')\n\ntype Model = keyof typeof modelMap\ntype Orient = keyof typeof orientMap\n\nexport const models = Object.keys(modelMap) as Model[]\nexport const orients = Object.keys(orientMap) as Orient[]\n\nexport namespace scheduler {\n  export const nai = ['native', 'karras', 'exponential', 'polyexponential'] as const\n  export const nai4 = ['karras', 'exponential', 'polyexponential'] as const\n  export const sd = ['Automatic', 'Uniform', 'Karras', 'Exponential', 'Polyexponential', 'SGM Uniform'] as const\n  export const horde = ['karras'] as const\n  export const comfyUI = ['normal', 'karras', 'exponential', 'sgm_uniform', 'simple', 'ddim_uniform'] as const\n}\n\nexport namespace sampler {\n  export const nai = {\n    'k_euler_a': 'Euler ancestral',\n    'k_euler': 'Euler',\n    'k_lms': 'LMS',\n    'ddim': 'DDIM',\n    'plms': 'PLMS',\n  }\n\n  export const nai3 = {\n    'k_euler': 'Euler',\n    'k_euler_a': 'Euler ancestral',\n    'k_dpmpp_2s_ancestral': 'DPM++ 2S ancestral',\n    'k_dpmpp_2m': 'DPM++ 2M',\n    'k_dpmpp_sde': 'DPM++ SDE',\n    'ddim_v3': 'DDIM V3',\n  }\n\n  export const nai4 = {\n    // recommended\n    'k_euler': 'Euler',\n    'k_euler_a': 'Euler ancestral',\n    'k_dpmpp_2s_ancestral': 'DPM++ 2S ancestral',\n    'k_dpmpp_2m_sde': 'DPM++ 2M SDE',\n    // other\n    'k_dpmpp_2m': 'DPM++ 2M',\n    'k_dpmpp_sde': 'DPM++ SDE',\n  }\n\n  // samplers in stable-diffusion-webui\n  // auto-generated by `build/fetch-sd-samplers.js`\n  export const sd = require('../data/sd-samplers.json') as Dict<string>\n\n  export const horde = {\n    k_lms: 'LMS',\n    k_heun: 'Heun',\n    k_euler: 'Euler',\n    k_euler_a: 'Euler a',\n    k_dpm_2: 'DPM2',\n    k_dpm_2_a: 'DPM2 a',\n    k_dpm_fast: 'DPM fast',\n    k_dpm_adaptive: 'DPM adaptive',\n    k_dpmpp_2m: 'DPM++ 2M',\n    k_dpmpp_2s_a: 'DPM++ 2S a',\n    k_dpmpp_sde: 'DPM++ SDE',\n    dpmsolver: 'DPM solver',\n    lcm: 'LCM',\n    DDIM: 'DDIM',\n  }\n\n  export const comfyui = {\n    euler: 'Euler',\n    euler_ancestral: 'Euler ancestral',\n    heun: 'Heun',\n    heunpp2: 'Heun++ 2',\n    dpm_2: 'DPM 2',\n    dpm_2_ancestral: 'DPM 2 ancestral',\n    lms: 'LMS',\n    dpm_fast: 'DPM fast',\n    dpm_adaptive: 'DPM adaptive',\n    dpmpp_2s_ancestral: 'DPM++ 2S ancestral',\n    dpmpp_sde: 'DPM++ SDE',\n    dpmpp_sde_gpu: 'DPM++ SDE GPU',\n    dpmpp_2m: 'DPM++ 2M',\n    dpmpp_2m_sde: 'DPM++ 2M SDE',\n    dpmpp_2m_sde_gpu: 'DPM++ 2M SDE GPU',\n    dpmpp_3m_sde: 'DPM++ 3M SDE',\n    dpmpp_3m_sde_gpu: 'DPM++ 3M SDE GPU',\n    ddpm: 'DDPM',\n    lcm: 'LCM',\n    ddim: 'DDIM',\n    uni_pc: 'UniPC',\n    uni_pc_bh2: 'UniPC BH2',\n  }\n\n  export function createSchema(map: Dict<string>) {\n    return Schema.union(Object.entries(map).map(([key, value]) => {\n      return Schema.const(key).description(value)\n    })).loose().description('默认的采样器。').default('k_euler')\n  }\n\n  export function sd2nai(sampler: string, model: string): string {\n    if (sampler === 'k_euler_a') return 'k_euler_ancestral'\n    if (model === 'nai-v3' && sampler in nai3) return sampler\n    else if (sampler in nai) return sampler\n    return 'k_euler_ancestral'\n  }\n}\n\nexport const upscalers = [\n  // built-in upscalers\n  'None',\n  'Lanczos',\n  'Nearest',\n  // third-party upscalers (might not be available)\n  'LDSR',\n  'ESRGAN_4x',\n  'R-ESRGAN General 4xV3',\n  'R-ESRGAN General WDN 4xV3',\n  'R-ESRGAN AnimeVideo',\n  'R-ESRGAN 4x+',\n  'R-ESRGAN 4x+ Anime6B',\n  'R-ESRGAN 2x+',\n  'ScuNET GAN',\n  'ScuNET PSNR',\n  'SwinIR 4x',\n] as const\n\nexport const latentUpscalers = [\n  'Latent',\n  'Latent (antialiased)',\n  'Latent (bicubic)',\n  'Latent (bicubic antialiased)',\n  'Latent (nearest)',\n  'Latent (nearest-exact)',\n]\n\nexport interface Options {\n  enhance: boolean\n  model: string\n  resolution: Size\n  sampler: string\n  seed: string\n  steps: number\n  scale: number\n  noise: number\n  strength: number\n}\n\nexport interface PromptConfig {\n  basePrompt?: Computed<string>\n  negativePrompt?: Computed<string>\n  forbidden?: Computed<string>\n  defaultPromptSw?: boolean\n  defaultPrompt?: Computed<string>\n  placement?: Computed<'before' | 'after'>\n  latinOnly?: Computed<boolean>\n  translator?: boolean\n  lowerCase?: boolean\n  maxWords?: Computed<number>\n}\n\nexport const PromptConfig: Schema<PromptConfig> = Schema.object({\n  basePrompt: Schema.computed(Schema.string().role('textarea'), options).description('默认附加的标签。').default('best quality, amazing quality, very aesthetic, absurdres'),\n  negativePrompt: Schema.computed(Schema.string().role('textarea'), options).description('默认附加的反向标签。').default(ucPreset),\n  forbidden: Schema.computed(Schema.string().role('textarea'), options).description('违禁词列表。请求中的违禁词将会被自动删除。').default(''),\n  defaultPromptSw: Schema.boolean().description('是否启用默认标签。').default(false),\n  defaultPrompt: Schema.string().role('textarea', options).description('默认标签，可以在用户无输入prompt时调用。可选在sd-webui中安装dynamic prompt插件，配合使用以达到随机标签效果。').default(''),\n  placement: Schema.computed(Schema.union([\n    Schema.const('before').description('置于最前'),\n    Schema.const('after').description('置于最后'),\n  ]), options).description('默认附加标签的位置。').default('after'),\n  translator: Schema.boolean().description('是否启用自动翻译。').default(true),\n  latinOnly: Schema.computed(Schema.boolean(), options).description('是否只接受英文输入。').default(false),\n  lowerCase: Schema.boolean().description('是否将输入的标签转换为小写。').default(true),\n  maxWords: Schema.computed(Schema.natural(), options).description('允许的最大单词数量。').default(0),\n}).description('输入设置')\n\ninterface FeatureConfig {\n  anlas?: Computed<boolean>\n  text?: Computed<boolean>\n  image?: Computed<boolean>\n  upscale?: Computed<boolean>\n}\n\nconst naiFeatures = Schema.object({\n  anlas: Schema.computed(Schema.boolean(), options).default(true).description('是否允许使用点数。'),\n})\n\nconst sdFeatures = Schema.object({\n  upscale: Schema.computed(Schema.boolean(), options).default(true).description('是否启用图片放大。'),\n})\n\nconst features = Schema.object({\n  text: Schema.computed(Schema.boolean(), options).default(true).description('是否启用文本转图片。'),\n  image: Schema.computed(Schema.boolean(), options).default(true).description('是否启用图片转图片。'),\n})\n\ninterface ParamConfig {\n  model?: Model\n  sampler?: string\n  smea?: boolean\n  smeaDyn?: boolean\n  scheduler?: string\n  rescale?: Computed<number>\n  decrisper?: boolean\n  upscaler?: string\n  restoreFaces?: boolean\n  hiresFix?: boolean\n  hiresFixUpscaler: string\n  scale?: Computed<number>\n  textSteps?: Computed<number>\n  imageSteps?: Computed<number>\n  maxSteps?: Computed<number>\n  strength?: Computed<number>\n  noise?: Computed<number>\n  resolution?: Computed<Orient | Size>\n  maxResolution?: Computed<number>\n}\n\nexport interface Config extends PromptConfig, ParamConfig {\n  type: 'token' | 'login' | 'naifu' | 'sd-webui' | 'stable-horde' | 'comfyui'\n  token?: string\n  email?: string\n  password?: string\n  authLv?: Computed<number>\n  authLvDefault?: Computed<number>\n  output?: Computed<'minimal' | 'default' | 'verbose'>\n  features?: FeatureConfig\n  apiEndpoint?: string\n  endpoint?: string\n  headers?: Dict<string>\n  nsfw?: Computed<'disallow' | 'censor' | 'allow'>\n  maxIterations?: number\n  maxRetryCount?: number\n  requestTimeout?: number\n  recallTimeout?: number\n  maxConcurrency?: number\n  pollInterval?: number\n  trustedWorkers?: boolean\n  workflowText2Image?: string\n  workflowImage2Image?: string\n}\n\nconst NAI4ParamConfig = Schema.object({\n  sampler: sampler.createSchema(sampler.nai4).default('k_euler_a'),\n  scheduler: Schema.union(scheduler.nai4).description('默认的调度器。').default('karras'),\n  rescale: Schema.computed(Schema.number(), options).min(0).max(1).description('输入服从度调整规模。').default(0),\n})\n\nexport const Config = Schema.intersect([\n  Schema.object({\n    type: Schema.union([\n      Schema.const('token').description('授权令牌'),\n      ...process.env.KOISHI_ENV === 'browser' ? [] : [Schema.const('login').description('账号密码')],\n      Schema.const('naifu').description('naifu'),\n      Schema.const('sd-webui').description('sd-webui'),\n      Schema.const('stable-horde').description('Stable Horde'),\n      Schema.const('comfyui').description('ComfyUI'),\n    ]).default('token').description('登录方式。'),\n  }).description('登录设置'),\n\n  Schema.union([\n    Schema.intersect([\n      Schema.union([\n        Schema.object({\n          type: Schema.const('token'),\n          token: Schema.string().description('授权令牌。').role('secret').required(),\n        }),\n        Schema.object({\n          type: Schema.const('login'),\n          email: Schema.string().description('账号邮箱。').required(),\n          password: Schema.string().description('账号密码。').role('secret').required(),\n        }),\n      ]),\n      Schema.object({\n        apiEndpoint: Schema.string().description('API 服务器地址。').default('https://api.novelai.net'),\n        endpoint: Schema.string().description('图片生成服务器地址。').default('https://image.novelai.net'),\n        headers: Schema.dict(String).role('table').description('要附加的额外请求头。').default({\n          'referer': 'https://novelai.net/',\n          'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',\n        }),\n      }),\n    ]),\n    Schema.object({\n      type: Schema.const('naifu'),\n      token: Schema.string().description('授权令牌。').role('secret'),\n      endpoint: Schema.string().description('API 服务器地址。').required(),\n      headers: Schema.dict(String).role('table').description('要附加的额外请求头。'),\n    }),\n    Schema.object({\n      type: Schema.const('sd-webui'),\n      endpoint: Schema.string().description('API 服务器地址。').required(),\n      headers: Schema.dict(String).role('table').description('要附加的额外请求头。'),\n    }),\n    Schema.object({\n      type: Schema.const('stable-horde'),\n      endpoint: Schema.string().description('API 服务器地址。').default('https://stablehorde.net/'),\n      token: Schema.string().description('授权令牌 (API Key)。').role('secret').default('0000000000'),\n      nsfw: Schema.union([\n        Schema.const('disallow').description('禁止'),\n        Schema.const('censor').description('屏蔽'),\n        Schema.const('allow').description('允许'),\n      ]).description('是否允许 NSFW 内容。').default('allow'),\n      trustedWorkers: Schema.boolean().description('是否只请求可信任工作节点。').default(false),\n      pollInterval: Schema.number().role('time').description('轮询进度间隔时长。').default(Time.second),\n    }),\n    Schema.object({\n      type: Schema.const('comfyui'),\n      endpoint: Schema.string().description('API 服务器地址。').required(),\n      headers: Schema.dict(String).role('table').description('要附加的额外请求头。'),\n      pollInterval: Schema.number().role('time').description('轮询进度间隔时长。').default(Time.second),\n    }),\n  ]),\n\n  Schema.object({\n    authLv: Schema.computed(Schema.natural(), options).description('使用画图全部功能所需要的权限等级。').default(0),\n    authLvDefault: Schema.computed(Schema.natural(), options).description('使用默认参数生成所需要的权限等级。').default(0),\n  }).description('权限设置'),\n\n  Schema.object({\n    features: Schema.object({}),\n  }).description('功能设置'),\n\n  Schema.union([\n    Schema.object({\n      type: Schema.union(['token', 'login']).hidden(),\n      features: Schema.intersect([naiFeatures, features]),\n    }),\n    Schema.object({\n      type: Schema.const('sd-webui'),\n      features: Schema.intersect([features, sdFeatures]),\n    }),\n    Schema.object({\n      features: Schema.intersect([features]),\n    }),\n  ]),\n\n  Schema.object({}).description('参数设置'),\n\n  Schema.union([\n    Schema.object({\n      type: Schema.const('sd-webui').required(),\n      sampler: sampler.createSchema(sampler.sd),\n      upscaler: Schema.union(upscalers).description('默认的放大算法。').default('Lanczos'),\n      restoreFaces: Schema.boolean().description('是否启用人脸修复。').default(false),\n      hiresFix: Schema.boolean().description('是否启用高分辨率修复。').default(false),\n      hiresFixUpscaler: Schema.union(latentUpscalers.concat(upscalers)).description('高分辨率修复的放大算法。').default('Latent'),\n      scheduler: Schema.union(scheduler.sd).description('默认的调度器。').default('Automatic'),\n    }),\n    Schema.object({\n      type: Schema.const('stable-horde').required(),\n      sampler: sampler.createSchema(sampler.horde),\n      model: Schema.union(hordeModels).loose().description('默认的生成模型。'),\n      scheduler: Schema.union(scheduler.horde).description('默认的调度器。').default('karras'),\n    }),\n    Schema.object({\n      type: Schema.const('naifu').required(),\n      sampler: sampler.createSchema(sampler.nai),\n    }),\n    Schema.object({\n      type: Schema.const('comfyui').required(),\n      sampler: sampler.createSchema(sampler.comfyui).description('默认的采样器。').required(),\n      model: Schema.string().description('默认的生成模型的文件名。').required(),\n      workflowText2Image: Schema.path({\n        filters: [{ name: '', extensions: ['.json'] }],\n        allowCreate: true,\n      }).description('API 格式的文本到图像工作流。'),\n      workflowImage2Image: Schema.path({\n        filters: [{ name: '', extensions: ['.json'] }],\n        allowCreate: true,\n      }).description('API 格式的图像到图像工作流。'),\n      scheduler: Schema.union(scheduler.comfyUI).description('默认的调度器。').default('normal'),\n    }),\n    Schema.intersect([\n      Schema.object({\n        model: Schema.union(models).loose().description('默认的生成模型。').default('nai-v3'),\n      }),\n      Schema.union([\n        Schema.object({\n          model: Schema.const('nai-v3'),\n          sampler: sampler.createSchema(sampler.nai3),\n          smea: Schema.boolean().description('默认启用 SMEA。'),\n          smeaDyn: Schema.boolean().description('默认启用 SMEA 采样器的 DYN 变体。'),\n          scheduler: Schema.union(scheduler.nai).description('默认的调度器。').default('native'),\n        }),\n        Schema.object({\n          model: Schema.const('nai-v4-curated-preview'),\n          ...NAI4ParamConfig.dict,\n        }),\n        Schema.object({\n          model: Schema.const('nai-v4-full'),\n          ...NAI4ParamConfig.dict,\n        }),\n        Schema.object({ sampler: sampler.createSchema(sampler.nai) }),\n      ]),\n      Schema.object({ decrisper: Schema.boolean().description('默认启用 decrisper') }),\n    ]),\n  ] as const),\n\n  Schema.object({\n    scale: Schema.computed(Schema.number(), options).description('默认对输入的服从度。').default(5),\n    textSteps: Schema.computed(Schema.natural(), options).description('文本生图时默认的迭代步数。').default(28),\n    imageSteps: Schema.computed(Schema.natural(), options).description('以图生图时默认的迭代步数。').default(50),\n    maxSteps: Schema.computed(Schema.natural(), options).description('允许的最大迭代步数。').default(64),\n    strength: Schema.computed(Schema.number(), options).min(0).max(1).description('默认的重绘强度。').default(0.7),\n    noise: Schema.computed(Schema.number(), options).min(0).max(1).description('默认的重绘添加噪声强度。').default(0.2),\n    resolution: Schema.computed(Schema.union([\n      Schema.const('portrait').description('肖像 (832x2326)'),\n      Schema.const('landscape').description('风景 (1216x832)'),\n      Schema.const('square').description('方形 (1024x1024)'),\n      Schema.object({\n        width: Schema.natural().description('图片宽度。').default(1024),\n        height: Schema.natural().description('图片高度。').default(1024),\n      }).description('自定义'),\n    ]), options).description('默认生成的图片尺寸。').default('portrait'),\n    maxResolution: Schema.computed(Schema.natural(), options).description('允许生成的宽高最大值。').default(1920),\n  }),\n\n  PromptConfig,\n\n  Schema.object({\n    output: Schema.union([\n      Schema.const('minimal').description('只发送图片'),\n      Schema.const('default').description('发送图片和关键信息'),\n      Schema.const('verbose').description('发送全部信息'),\n    ]).description('输出方式。').default('default'),\n    maxIterations: Schema.natural().description('允许的最大绘制次数。').default(1),\n    maxRetryCount: Schema.natural().description('连接失败时最大的重试次数。').default(3),\n    requestTimeout: Schema.number().role('time').description('当请求超过这个时间时会中止并提示超时。').default(Time.minute),\n    recallTimeout: Schema.number().role('time').description('图片发送后自动撤回的时间 (设置为 0 以禁用此功能)。').default(0),\n    maxConcurrency: Schema.number().description('单个频道下的最大并发数量 (设置为 0 以禁用此功能)。').default(0),\n  }).description('高级设置'),\n]) as Schema<Config>\n\ninterface Forbidden {\n  pattern: string\n  strict: boolean\n}\n\nexport function parseForbidden(input: string) {\n  return input.trim()\n    .toLowerCase()\n    .replace(/，/g, ',')\n    .replace(/！/g, '!')\n    .split(/(?:,\\s*|\\s*\\n\\s*)/g)\n    .filter(Boolean)\n    .map<Forbidden>((pattern: string) => {\n      const strict = pattern.endsWith('!')\n      if (strict) pattern = pattern.slice(0, -1)\n      pattern = pattern.replace(/[^a-z0-9\\u00ff-\\uffff:]+/g, ' ').trim()\n      return { pattern, strict }\n    })\n}\n\nconst backslash = /@@__BACKSLASH__@@/g\n\nexport function parseInput(session: Session, input: string, config: Config, override: boolean): string[] {\n  if (!input) {\n    return [\n      null,\n      [session.resolve(config.basePrompt), session.resolve(config.defaultPrompt)].join(','),\n      session.resolve(config.negativePrompt)\n    ]\n  }\n\n  input = input\n    .replace(/\\\\\\\\/g, backslash.source)\n    .replace(/，/g, ',')\n    .replace(/（/g, '(')\n    .replace(/）/g, ')')\n    .replace(/《/g, '<')\n    .replace(/》/g, '>')\n\n  if (config.type === 'sd-webui') {\n    input = input\n      .split('\\\\{').map(s => s.replace(/\\{/g, '(')).join('\\\\{')\n      .split('\\\\}').map(s => s.replace(/\\}/g, ')')).join('\\\\}')\n  } else {\n    input = input\n      .split('\\\\(').map(s => s.replace(/\\(/g, '{')).join('\\\\(')\n      .split('\\\\)').map(s => s.replace(/\\)/g, '}')).join('\\\\)')\n  }\n\n  input = input\n    .replace(backslash, '\\\\')\n    .replace(/_/g, ' ')\n\n  if (session.resolve(config.latinOnly) && /[^\\s\\w\"'“”‘’.,:|\\\\()\\[\\]{}<>-]/.test(input)) {\n    return ['.latin-only']\n  }\n\n  const negative = []\n  const placement = session.resolve(config.placement)\n  const appendToList = (words: string[], input = '') => {\n    const tags = input.split(/,\\s*/g)\n    if (placement === 'before') tags.reverse()\n    for (let tag of tags) {\n      tag = tag.trim()\n      if (config.lowerCase) tag = tag.toLowerCase()\n      if (!tag || words.includes(tag)) continue\n      if (placement === 'before') {\n        words.unshift(tag)\n      } else {\n        words.push(tag)\n      }\n    }\n  }\n\n  // extract negative prompts\n  const capture = input.match(/(,\\s*|\\s+)(-u\\s+|--undesired\\s+|negative prompts?:\\s*)([\\s\\S]+)/m)\n  if (capture?.[3]) {\n    input = input.slice(0, capture.index).trim()\n    appendToList(negative, capture[3])\n  }\n\n  // remove forbidden words\n  const forbidden = parseForbidden(session.resolve(config.forbidden))\n  const positive = input.split(/,\\s*/g).filter((word) => {\n    // eslint-disable-next-line no-control-regex\n    word = word.toLowerCase().replace(/[\\x00-\\x7f]/g, s => s.replace(/[^0-9a-zA-Z]/, ' ')).replace(/\\s+/, ' ').trim()\n    if (!word) return false\n    for (const { pattern, strict } of forbidden) {\n      if (strict && word.split(/\\W+/g).includes(pattern)) {\n        return false\n      } else if (!strict && word.includes(pattern)) {\n        return false\n      }\n    }\n    return true\n  }).map((word) => {\n    if (/^<.+>$/.test(word)) return word.replace(/ /g, '_')\n    return word.toLowerCase()\n  })\n\n  if (Math.max(getWordCount(positive), getWordCount(negative)) > (session.resolve(config.maxWords) || Infinity)) {\n    return ['.too-many-words']\n  }\n\n  if (!override) {\n    appendToList(positive, session.resolve(config.basePrompt))\n    appendToList(negative, session.resolve(config.negativePrompt))\n    if (config.defaultPromptSw) appendToList(positive, session.resolve(config.defaultPrompt))\n  }\n\n  return [null, positive.join(', '), negative.join(', ')]\n}\n\nfunction getWordCount(words: string[]) {\n  return words.join(' ').replace(/[^a-z0-9]+/g, ' ').trim().split(' ').length\n}\n"
  },
  {
    "path": "src/index.ts",
    "content": "import { Computed, Context, Dict, h, omit, Quester, Session, SessionError, trimSlash } from 'koishi'\nimport { Config, modelMap, models, orientMap, parseInput, sampler, upscalers, scheduler } from './config'\nimport { ImageData, NovelAI, StableDiffusionWebUI } from './types'\nimport { closestMultiple, download, forceDataPrefix, getImageSize, login, NetworkError, project, resizeInput, Size } from './utils'\nimport { } from '@koishijs/translator'\nimport { } from '@koishijs/plugin-help'\nimport AdmZip from 'adm-zip'\nimport { resolve } from 'path'\nimport { readFile } from 'fs/promises'\n\nexport * from './config'\n\nexport const reactive = true\nexport const name = 'novelai'\n\nfunction handleError({ logger }: Context, session: Session, err: Error) {\n  if (Quester.Error.is(err)) {\n    if (err.response?.status === 402) {\n      return session.text('.unauthorized')\n    } else if (err.response?.status) {\n      return session.text('.response-error', [err.response.status])\n    } else if (err.code === 'ETIMEDOUT') {\n      return session.text('.request-timeout')\n    } else if (err.code) {\n      return session.text('.request-failed', [err.code])\n    }\n  }\n  logger.error(err)\n  return session.text('.unknown-error')\n}\n\nexport const inject = {\n  required: ['http'],\n  optional: ['translator'],\n}\n\nexport function apply(ctx: Context, config: Config) {\n  ctx.i18n.define('zh-CN', require('./locales/zh-CN'))\n  ctx.i18n.define('zh-TW', require('./locales/zh-TW'))\n  ctx.i18n.define('en-US', require('./locales/en-US'))\n  ctx.i18n.define('fr-FR', require('./locales/fr-FR'))\n  ctx.i18n.define('ja-JP', require('./locales/ja-JP'))\n\n  const tasks: Dict<Set<string>> = Object.create(null)\n  const globalTasks = new Set<string>()\n\n  let tokenTask: Promise<string> = null\n  const getToken = () => tokenTask ||= login(ctx)\n  ctx.accept(['token', 'type', 'email', 'password'], () => tokenTask = null)\n\n  type HiddenCallback = (session: Session<'authority'>) => boolean\n\n  const useFilter = (filter: Computed<boolean>): HiddenCallback => (session) => {\n    return session.resolve(filter) ?? true\n  }\n\n  const useBackend = (...types: Config['type'][]): HiddenCallback => () => {\n    return types.includes(config.type)\n  }\n\n  const thirdParty = () => !['login', 'token'].includes(config.type)\n\n  const restricted: HiddenCallback = (session) => {\n    return !thirdParty() && useFilter(config.features.anlas)(session)\n  }\n\n  const noImage: HiddenCallback = (session) => {\n    return !useFilter(config.features.image)(session)\n  }\n\n  const some = (...args: HiddenCallback[]): HiddenCallback => (session) => {\n    return args.some(callback => callback(session))\n  }\n\n  const step = (source: string, session: Session) => {\n    const value = +source\n    if (value * 0 === 0 && Math.floor(value) === value && value > 0 && value <= session.resolve(config.maxSteps || Infinity)) return value\n    throw new Error()\n  }\n\n  const resolution = (source: string, session: Session<'authority'>): Size => {\n    if (source in orientMap) return orientMap[source]\n    const cap = source.match(/^(\\d+)[x×](\\d+)$/)\n    if (!cap) throw new Error()\n    const width = closestMultiple(+cap[1])\n    const height = closestMultiple(+cap[2])\n    if (Math.max(width, height) > session.resolve(config.maxResolution || Infinity)) {\n      throw new SessionError('commands.novelai.messages.invalid-resolution')\n    }\n    return { width, height, custom: true }\n  }\n\n  const cmd = ctx.command('novelai <prompts:text>')\n    .alias('nai')\n    .alias('imagine')\n    .userFields(['authority'])\n    .shortcut('imagine', { i18n: true, fuzzy: true })\n    .shortcut('enhance', { i18n: true, fuzzy: true, options: { enhance: true } })\n    .option('enhance', '-e', { hidden: some(restricted, thirdParty, noImage) })\n    .option('model', '-m <model>', { type: models, hidden: thirdParty })\n    .option('resolution', '-r <resolution>', { type: resolution })\n    .option('output', '-o', { type: ['minimal', 'default', 'verbose'] })\n    .option('override', '-O', { hidden: restricted })\n    .option('sampler', '-s <sampler>')\n    .option('seed', '-x <seed:number>')\n    .option('steps', '-t <step>', { type: step, hidden: restricted })\n    .option('scale', '-c <scale:number>')\n    .option('noise', '-n <noise:number>', { hidden: some(restricted, thirdParty) })\n    .option('strength', '-N <strength:number>', { hidden: restricted })\n    .option('hiresFix', '-H', { hidden: () => config.type !== 'sd-webui' })\n    .option('hiresFixSteps', '<step>', { type: step, hidden: () => config.type !== 'sd-webui' })\n    .option('smea', '-S', { hidden: () => config.model !== 'nai-v3' })\n    .option('smeaDyn', '-d', { hidden: () => config.model !== 'nai-v3' })\n    .option('scheduler', '-C <scheduler:string>', {\n      hidden: () => config.type === 'naifu',\n      type: ['token', 'login'].includes(config.type)\n        ? scheduler.nai\n        : config.type === 'sd-webui'\n        ? scheduler.sd\n        : config.type === 'stable-horde'\n        ? scheduler.horde\n        : [],\n    })\n    .option('decrisper', '-D', { hidden: thirdParty })\n    .option('undesired', '-u <undesired>')\n    .option('noTranslator', '-T', { hidden: () => !ctx.translator || !config.translator })\n    .option('iterations', '-i <iterations:posint>', { fallback: 1, hidden: () => config.maxIterations <= 1 })\n    .option('batch', '-b <batch:option>', { fallback: 1, hidden: () => config.maxIterations <= 1 })\n    .action(async ({ session, options }, input) => {\n      if (config.defaultPromptSw) {\n        if (session.user.authority < session.resolve(config.authLvDefault)) {\n          return session.text('internal.low-authority')\n        }\n        if (session.user.authority < session.resolve(config.authLv)) {\n          input = ''\n          options = options.resolution ? { resolution: options.resolution } : {}\n        }\n      } else if (\n        !config.defaultPromptSw\n        && session.user.authority < session.resolve(config.authLv)\n      ) return session.text('internal.low-auth')\n\n      const haveInput = !!input?.trim()\n      if (!haveInput && !config.defaultPromptSw) return session.execute('help novelai')\n\n      // Check if the user is allowed to use this command.\n      // This code is originally written in the `resolution` function,\n      // but currently `session.user` is not available in the type infering process.\n      // See: https://github.com/koishijs/novelai-bot/issues/159\n      if (options.resolution?.custom && restricted(session)) {\n        return session.text('.custom-resolution-unsupported')\n      }\n\n      const { batch = 1, iterations = 1 } = options\n      const total = batch * iterations\n      if (total > config.maxIterations) {\n        return session.text('.exceed-max-iteration', [config.maxIterations])\n      }\n\n      const allowText = useFilter(config.features.text)(session)\n      const allowImage = useFilter(config.features.image)(session)\n\n      let imgUrl: string, image: ImageData\n      if (!restricted(session) && haveInput) {\n        input = h('', h.transform(h.parse(input), {\n          img(attrs) {\n            if (!allowImage) throw new SessionError('commands.novelai.messages.invalid-content')\n            if (imgUrl) throw new SessionError('commands.novelai.messages.too-many-images')\n            imgUrl = attrs.src\n            return ''\n          },\n        })).toString(true)\n\n        if (options.enhance && !imgUrl) {\n          return session.text('.expect-image')\n        }\n\n        if (!input.trim() && !config.basePrompt) {\n          return session.text('.expect-prompt')\n        }\n      } else {\n        input = haveInput ? h('', h.transform(h.parse(input), {\n          image(attrs) {\n            throw new SessionError('commands.novelai.messages.invalid-content')\n          },\n        })).toString(true) : input\n        delete options.enhance\n        delete options.steps\n        delete options.noise\n        delete options.strength\n        delete options.override\n      }\n\n      if (!allowText && !imgUrl) {\n        return session.text('.expect-image')\n      }\n\n      if (haveInput && config.translator && ctx.translator && !options.noTranslator) {\n        try {\n          input = await ctx.translator.translate({ input, target: 'en' })\n        } catch (err) {\n          ctx.logger.warn(err)\n        }\n      }\n\n      const [errPath, prompt, uc] = parseInput(session, input, config, options.override)\n      if (errPath) return session.text(errPath)\n\n      let token: string\n      try {\n        token = await getToken()\n      } catch (err) {\n        if (err instanceof NetworkError) {\n          return session.text(err.message, err.params)\n        }\n        ctx.logger.error(err)\n        return session.text('.unknown-error')\n      }\n\n      const model = modelMap[options.model]\n      const seed = options.seed || Math.floor(Math.random() * Math.pow(2, 32))\n\n      const parameters: Dict = {\n        seed,\n        prompt,\n        n_samples: options.batch,\n        uc,\n        // 0: low quality + bad anatomy\n        // 1: low quality\n        // 2: none\n        ucPreset: 2,\n        qualityToggle: false,\n        scale: options.scale ?? session.resolve(config.scale),\n        steps: options.steps ?? session.resolve(imgUrl ? config.imageSteps : config.textSteps),\n      }\n\n      if (imgUrl) {\n        try {\n          image = await download(ctx, imgUrl)\n        } catch (err) {\n          if (err instanceof NetworkError) {\n            return session.text(err.message, err.params)\n          }\n          ctx.logger.error(err)\n          return session.text('.download-error')\n        }\n\n        if (options.enhance) {\n          const size = getImageSize(image.buffer)\n          if (size.width + size.height !== 1280) {\n            return session.text('.invalid-size')\n          }\n          Object.assign(parameters, {\n            height: size.height * 1.5,\n            width: size.width * 1.5,\n            noise: options.noise ?? 0,\n            strength: options.strength ?? 0.2,\n          })\n        } else {\n          options.resolution ||= resizeInput(getImageSize(image.buffer))\n          Object.assign(parameters, {\n            height: options.resolution.height,\n            width: options.resolution.width,\n            noise: options.noise ?? session.resolve(config.noise),\n            strength: options.strength ?? session.resolve(config.strength),\n          })\n        }\n      } else {\n        if (!options.resolution) {\n          const resolution = session.resolve(config.resolution)\n          options.resolution = typeof resolution === 'string' ? orientMap[resolution] : resolution\n        }\n        Object.assign(parameters, {\n          height: options.resolution.height,\n          width: options.resolution.width,\n        })\n      }\n\n      if (options.hiresFix || config.hiresFix) {\n        // set default denoising strength to `0.75` for `hires fix` feature\n        // https://github.com/koishijs/novelai-bot/issues/158\n        parameters.strength ??= session.resolve(config.strength)\n      }\n\n      const getRandomId = () => Math.random().toString(36).slice(2)\n      const container = Array(iterations).fill(0).map(getRandomId)\n      if (config.maxConcurrency) {\n        const store = tasks[session.cid] ||= new Set()\n        if (store.size >= config.maxConcurrency) {\n          return session.text('.concurrent-jobs')\n        } else {\n          container.forEach((id) => store.add(id))\n        }\n      }\n\n      session.send(globalTasks.size\n        ? session.text('.pending', [globalTasks.size])\n        : session.text('.waiting'))\n\n      container.forEach((id) => globalTasks.add(id))\n      const cleanUp = (id: string) => {\n        tasks[session.cid]?.delete(id)\n        globalTasks.delete(id)\n      }\n\n      const path = (() => {\n        switch (config.type) {\n          case 'sd-webui':\n            return image ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'\n          case 'stable-horde':\n            return '/api/v2/generate/async'\n          case 'naifu':\n            return '/generate-stream'\n          case 'comfyui':\n            return '/prompt'\n          default:\n            return '/ai/generate-image'\n        }\n      })()\n\n      const getPayload = async () => {\n        switch (config.type) {\n          case 'login':\n          case 'token':\n          case 'naifu': {\n            parameters.params_version = 1\n            parameters.sampler = sampler.sd2nai(options.sampler, model)\n            parameters.image = image?.base64 // NovelAI / NAIFU accepts bare base64 encoded image\n            if (config.type === 'naifu') return parameters\n            // The latest interface changes uc to negative_prompt, so that needs to be changed here as well\n            if (parameters.uc) {\n              parameters.negative_prompt = parameters.uc\n              delete parameters.uc\n            }\n            parameters.dynamic_thresholding = options.decrisper ?? config.decrisper\n            const isNAI3 = model === 'nai-diffusion-3'\n            const isNAI4 = model === 'nai-diffusion-4-curated-preview' || model === 'nai-diffusion-4-full'\n            if (isNAI3 || isNAI4) {\n              parameters.params_version = 3\n              parameters.legacy = false\n              parameters.legacy_v3_extend = false\n              parameters.noise_schedule = options.scheduler ?? config.scheduler\n              // Max scale for nai-v3 is 10, but not 20.\n              // If the given value is greater than 10,\n              // we can assume it is configured with an older version (max 20)\n              if (parameters.scale > 10) {\n                parameters.scale = parameters.scale / 2\n              }\n              if (isNAI3) {\n                parameters.sm_dyn = options.smeaDyn ?? config.smeaDyn\n                parameters.sm = (options.smea ?? config.smea) || parameters.sm_dyn\n                if (['k_euler_ancestral', 'k_dpmpp_2s_ancestral'].includes(parameters.sampler)\n                  && parameters.noise_schedule === 'karras') {\n                  parameters.noise_schedule = 'native'\n                }\n                if (parameters.sampler === 'ddim_v3') {\n                  parameters.sm = false\n                  parameters.sm_dyn = false\n                  delete parameters.noise_schedule\n                }\n              } else if (isNAI4) {\n                parameters.add_original_image = true // unknown\n                parameters.cfg_rescale = session.resolve(config.rescale)\n                parameters.characterPrompts = [] satisfies NovelAI.V4CharacterPrompt[]\n                parameters.controlnet_strength = 1 // unknown\n                parameters.deliberate_euler_ancestral_bug = false // unknown\n                parameters.prefer_brownian = true // unknown\n                parameters.reference_image_multiple = [] // unknown\n                parameters.reference_information_extracted_multiple = [] // unknown\n                parameters.reference_strength_multiple = [] // unknown\n                parameters.skip_cfg_above_sigma = null // unknown\n                parameters.use_coords = false // unknown\n                parameters.v4_prompt = {\n                  caption: {\n                    base_caption: prompt,\n                    char_captions: [],\n                  },\n                  use_coords: parameters.use_coords,\n                  use_order: true,\n                } satisfies NovelAI.V4PromptPositive\n                parameters.v4_negative_prompt = {\n                  caption: {\n                    base_caption: parameters.negative_prompt,\n                    char_captions: [],\n                  },\n                } satisfies NovelAI.V4Prompt\n              }\n            }\n            return { model, input: prompt, action: 'generate', parameters: omit(parameters, ['prompt']) }\n          }\n          case 'sd-webui': {\n            return {\n              sampler_index: sampler.sd[options.sampler],\n              scheduler: options.scheduler,\n              init_images: image && [image.dataUrl], // sd-webui accepts data URLs with base64 encoded image\n              restore_faces: config.restoreFaces ?? false,\n              enable_hr: options.hiresFix ?? config.hiresFix ?? false,\n              hr_second_pass_steps: options.hiresFixSteps ?? 0,\n              hr_upscaler: config.hiresFixUpscaler ?? 'None',\n              ...project(parameters, {\n                prompt: 'prompt',\n                batch_size: 'n_samples',\n                seed: 'seed',\n                negative_prompt: 'uc',\n                cfg_scale: 'scale',\n                steps: 'steps',\n                width: 'width',\n                height: 'height',\n                denoising_strength: 'strength',\n              }),\n            }\n          }\n          case 'stable-horde': {\n            const nsfw = session.resolve(config.nsfw)\n            return {\n              prompt: parameters.prompt,\n              params: {\n                sampler_name: options.sampler,\n                cfg_scale: parameters.scale,\n                denoising_strength: parameters.strength,\n                seed: parameters.seed.toString(),\n                height: parameters.height,\n                width: parameters.width,\n                post_processing: [],\n                karras: options.scheduler?.toLowerCase() === 'karras',\n                hires_fix: options.hiresFix ?? config.hiresFix ?? false,\n                steps: parameters.steps,\n                n: parameters.n_samples,\n              },\n              nsfw: nsfw !== 'disallow',\n              trusted_workers: config.trustedWorkers,\n              censor_nsfw: nsfw === 'censor',\n              models: [options.model],\n              source_image: image?.base64,\n              source_processing: image ? 'img2img' : undefined,\n              // support r2 upload\n              // https://github.com/koishijs/novelai-bot/issues/163\n              r2: true,\n            }\n          }\n          case 'comfyui': {\n            const workflowText2Image = config.workflowText2Image\n              ? resolve(ctx.baseDir, config.workflowText2Image)\n              : resolve(__dirname, '../data/default-comfyui-t2i-wf.json')\n            const workflowImage2Image = config.workflowImage2Image\n              ? resolve(ctx.baseDir, config.workflowImage2Image)\n              : resolve(__dirname, '../data/default-comfyui-i2i-wf.json')\n            const workflow = image ? workflowImage2Image : workflowText2Image\n            ctx.logger.debug('workflow:', workflow)\n            const prompt = JSON.parse(await readFile(workflow, 'utf8'))\n\n            // have to upload image to the comfyui server first\n            if (image) {\n              const body = new FormData()\n              const capture = /^data:([\\w/.+-]+);base64,(.*)$/.exec(image.dataUrl)\n              const [, mime] = capture\n\n              let name = Date.now().toString()\n              const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : ''\n              if (ext) name += `.${ext}`\n              const imageFile = new Blob([image.buffer], { type: mime })\n              body.append('image', imageFile, name)\n              const res = await ctx.http(trimSlash(config.endpoint) + '/upload/image', {\n                method: 'POST',\n                headers: {\n                  ...config.headers,\n                },\n                data: body,\n              })\n              if (res.status === 200) {\n                const data = res.data\n                let imagePath = data.name\n                if (data.subfolder) imagePath = data.subfolder + '/' + imagePath\n\n                for (const nodeId in prompt) {\n                  if (prompt[nodeId].class_type === 'LoadImage') {\n                    prompt[nodeId].inputs.image = imagePath\n                    break\n                  }\n                }\n              } else {\n                throw new SessionError('commands.novelai.messages.unknown-error')\n              }\n            }\n\n            // only change the first node in the workflow\n            for (const nodeId in prompt) {\n              if (prompt[nodeId].class_type === 'KSampler') {\n                prompt[nodeId].inputs.seed = parameters.seed\n                prompt[nodeId].inputs.steps = parameters.steps\n                prompt[nodeId].inputs.cfg = parameters.scale\n                prompt[nodeId].inputs.sampler_name = options.sampler\n                prompt[nodeId].inputs.denoise = options.strength ?? session.resolve(config.strength)\n                prompt[nodeId].inputs.scheduler = options.scheduler ?? config.scheduler\n                const positiveNodeId = prompt[nodeId].inputs.positive[0]\n                const negativeeNodeId = prompt[nodeId].inputs.negative[0]\n                const latentImageNodeId = prompt[nodeId].inputs.latent_image[0]\n                prompt[positiveNodeId].inputs.text = parameters.prompt\n                prompt[negativeeNodeId].inputs.text = parameters.uc\n                prompt[latentImageNodeId].inputs.width = parameters.width\n                prompt[latentImageNodeId].inputs.height = parameters.height\n                prompt[latentImageNodeId].inputs.batch_size = parameters.n_samples\n                break\n              }\n            }\n            for (const nodeId in prompt) {\n              if (prompt[nodeId].class_type === 'CheckpointLoaderSimple') {\n                prompt[nodeId].inputs.ckpt_name = options.model ?? config.model\n                break\n              }\n            }\n            ctx.logger.debug('prompt:', prompt)\n            return { prompt }\n          }\n        }\n      }\n\n      const getHeaders = () => {\n        switch (config.type) {\n          case 'login':\n          case 'token':\n          case 'naifu':\n            return { Authorization: `Bearer ${token}` }\n          case 'stable-horde':\n            return { apikey: token }\n        }\n      }\n\n      let finalPrompt = prompt\n      const iterate = async () => {\n        const request = async () => {\n          const res = await ctx.http(trimSlash(config.endpoint) + path, {\n            method: 'POST',\n            timeout: config.requestTimeout,\n            // Since novelai's latest interface returns an application/x-zip-compressed, a responseType must be passed in\n            responseType: config.type === 'naifu' ? 'text' : ['login', 'token'].includes(config.type) ? 'arraybuffer' : 'json',\n            headers: {\n              ...config.headers,\n              ...getHeaders(),\n            },\n            data: await getPayload(),\n          })\n\n          if (config.type === 'sd-webui') {\n            const data = res.data as StableDiffusionWebUI.Response\n            if (data?.info?.prompt) {\n              finalPrompt = data.info.prompt\n            } else {\n              try {\n                finalPrompt = (JSON.parse(data.info)).prompt\n              } catch (err) {\n                ctx.logger.warn(err)\n              }\n            }\n            return forceDataPrefix(data.images[0])\n          }\n          if (config.type === 'stable-horde') {\n            const uuid = res.data.id\n\n            const check = () => ctx.http.get(trimSlash(config.endpoint) + '/api/v2/generate/check/' + uuid).then((res) => res.done)\n            const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n            while (await check() === false) {\n              await sleep(config.pollInterval)\n            }\n            const result = await ctx.http.get(trimSlash(config.endpoint) + '/api/v2/generate/status/' + uuid)\n            const imgUrl = result.generations[0].img\n            if (!imgUrl.startsWith('http')) {\n              // r2 upload\n              // in case some client doesn't support r2 upload and follow the ye olde way.\n              return forceDataPrefix(result.generations[0].img, 'image/webp')\n            }\n            const imgRes = await ctx.http(imgUrl, { responseType: 'arraybuffer' })\n            const b64 = Buffer.from(imgRes.data).toString('base64')\n            return forceDataPrefix(b64, imgRes.headers.get('content-type'))\n          }\n          if (config.type === 'comfyui') {\n            // get filenames from history\n            const promptId = res.data.prompt_id\n            const check = () => ctx.http.get(trimSlash(config.endpoint) + '/history/' + promptId)\n              .then((res) => res[promptId] && res[promptId].outputs)\n            const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n            let outputs\n            while (!(outputs = await check())) {\n              await sleep(config.pollInterval)\n            }\n            // get images by filename\n            const imagesOutput: { data: ArrayBuffer; mime: string }[] = []\n            for (const nodeId in outputs) {\n              const nodeOutput = outputs[nodeId]\n              if ('images' in nodeOutput) {\n                for (const image of nodeOutput['images']) {\n                  const urlValues = new URLSearchParams({ filename: image['filename'], subfolder: image['subfolder'], type: image['type'] }).toString()\n                  const imgRes = await ctx.http(trimSlash(config.endpoint) + '/view?' + urlValues)\n                  imagesOutput.push({ data: imgRes.data, mime: imgRes.headers.get('content-type') })\n                  break\n                }\n              }\n            }\n            // return first image\n            return forceDataPrefix(Buffer.from(imagesOutput[0].data).toString('base64'), imagesOutput[0].mime)\n          }\n          // event: newImage\n          // id: 1\n          // data:\n          //                                                                        ↓ nai-v3\n          if (res.headers.get('content-type') === 'application/x-zip-compressed' || res.headers.get('content-disposition')?.includes('.zip')) {\n            const buffer = Buffer.from(res.data, 'binary') // Ensure 'binary' encoding\n            const zip = new AdmZip(buffer)\n\n            // Gets all files in the ZIP file\n            const zipEntries = zip.getEntries()\n            const firstImageBuffer = zip.readFile(zipEntries[0])\n            const b64 = firstImageBuffer.toString('base64')\n            return forceDataPrefix(b64, 'image/png')\n          }\n          return forceDataPrefix(res.data?.trimEnd().slice(27))\n        }\n\n        let dataUrl: string, count = 0\n        while (true) {\n          try {\n            dataUrl = await request()\n            break\n          } catch (err) {\n            if (Quester.Error.is(err)) {\n              if (err.code && err.code !== 'ETIMEDOUT' && ++count < config.maxRetryCount) {\n                continue\n              }\n            }\n            return await session.send(handleError(ctx, session, err))\n          }\n        }\n\n        if (!dataUrl.trim()) return await session.send(session.text('.empty-response'))\n\n        function getContent() {\n          const output = session.resolve(options.output ?? config.output)\n          if (output === 'minimal') return h.image(dataUrl)\n          const attrs = {\n            userId: session.userId,\n            nickname: session.author?.nickname || session.username,\n          }\n          const result = h('figure')\n          const lines = [`seed = ${parameters.seed}`]\n          if (output === 'verbose') {\n            if (!thirdParty()) {\n              lines.push(`model = ${model}`)\n            }\n            lines.push(\n              `sampler = ${options.sampler}`,\n              `steps = ${parameters.steps}`,\n              `scale = ${parameters.scale}`,\n            )\n            if (parameters.image) {\n              lines.push(\n                `strength = ${parameters.strength}`,\n                `noise = ${parameters.noise}`,\n              )\n            }\n          }\n          result.children.push(h('message', attrs, lines.join('\\n')))\n          result.children.push(h('message', attrs, `prompt = ${h.escape(finalPrompt)}`))\n          if (output === 'verbose') {\n            result.children.push(h('message', attrs, `undesired = ${h.escape(uc)}`))\n          }\n          result.children.push(h('message', attrs, h.image(dataUrl)))\n          return result\n        }\n\n        ctx.logger.debug(`${session.uid}: ${finalPrompt}`)\n        const messageIds = await session.send(getContent())\n        if (messageIds.length && config.recallTimeout) {\n          ctx.setTimeout(() => {\n            for (const id of messageIds) {\n              session.bot.deleteMessage(session.channelId, id)\n            }\n          }, config.recallTimeout)\n        }\n      }\n\n      while (container.length) {\n        try {\n          await iterate()\n          cleanUp(container.pop())\n          parameters.seed++\n        } catch (err) {\n          container.forEach(cleanUp)\n          throw err\n        }\n      }\n    })\n\n  ctx.accept(['model', 'sampler'], (config) => {\n    const getSamplers = () => {\n      switch (config.type) {\n        case 'sd-webui':\n          return sampler.sd\n        case 'stable-horde':\n          return sampler.horde\n        default:\n          return { ...sampler.nai, ...sampler.nai3 }\n      }\n    }\n\n    cmd._options.model.fallback = config.model\n    cmd._options.sampler.fallback = config.sampler\n    cmd._options.sampler.type = Object.keys(getSamplers())\n  }, { immediate: true })\n\n  const subcmd = ctx\n    .intersect(useBackend('sd-webui'))\n    .intersect(useFilter(config.features.upscale))\n    .command('novelai.upscale')\n    .shortcut('upscale', { i18n: true, fuzzy: true })\n    .option('scale', '-s <scale:number>', { fallback: 2 })\n    .option('resolution', '-r <resolution>', { type: resolution })\n    .option('crop', '-C, --no-crop', { value: false, fallback: true })\n    .option('upscaler', '-1 <upscaler>', { type: upscalers })\n    .option('upscaler2', '-2 <upscaler2>', { type: upscalers })\n    .option('visibility', '-v <visibility:number>')\n    .option('upscaleFirst', '-f', { fallback: false })\n    .action(async ({ session, options }, input) => {\n      let imgUrl: string\n      h.transform(input, {\n        image(attrs) {\n          imgUrl = attrs.url\n          return ''\n        },\n      })\n\n      if (!imgUrl) return session.text('.expect-image')\n      let image: ImageData\n      try {\n        image = await download(ctx, imgUrl)\n      } catch (err) {\n        if (err instanceof NetworkError) {\n          return session.text(err.message, err.params)\n        }\n        ctx.logger.error(err)\n        return session.text('.download-error')\n      }\n\n      const payload: StableDiffusionWebUI.ExtraSingleImageRequest = {\n        image: image.dataUrl,\n        resize_mode: options.resolution ? 1 : 0,\n        show_extras_results: true,\n        upscaling_resize: options.scale,\n        upscaling_resize_h: options.resolution?.height,\n        upscaling_resize_w: options.resolution?.width,\n        upscaling_crop: options.crop,\n        upscaler_1: options.upscaler,\n        upscaler_2: options.upscaler2 ?? 'None',\n        extras_upscaler_2_visibility: options.visibility ?? 1,\n        upscale_first: options.upscaleFirst,\n      }\n\n      try {\n        const { data } = await ctx.http<StableDiffusionWebUI.ExtraSingleImageResponse>(trimSlash(config.endpoint) + '/sdapi/v1/extra-single-image', {\n          method: 'POST',\n          timeout: config.requestTimeout,\n          headers: {\n            ...config.headers,\n          },\n          data: payload,\n        })\n        return h.image(forceDataPrefix(data.image))\n      } catch (e) {\n        ctx.logger.warn(e)\n        return session.text('.unknown-error')\n      }\n    })\n\n  ctx.accept(['upscaler'], (config) => {\n    subcmd._options.upscaler.fallback = config.upscaler\n  }, { immediate: true })\n}\n"
  },
  {
    "path": "src/locales/de-DE.yml",
    "content": "commands:\n  novelai:\n    description: AI 画图\n    usage: |-\n      输入用逗号隔开的英文标签，例如 Mr.Quin, dark sword, red eyes。\n      查找标签可以使用 Danbooru。\n      快来给仓库点个 star 吧：https://github.com/koishijs/novelai-bot\n    options:\n      enhance: 图片增强模式\n      model: 设定生成模型\n      resolution: 设定图片尺寸\n      override: 禁用默认标签\n      sampler: 设置采样器\n      seed: 设置随机种子\n      steps: 设置迭代步数\n      scale: 设置对输入的服从度\n      strength: 图片修改幅度\n      noise: 图片噪声强度\n      hiresFix: 启用高分辨率修复\n      undesired: 排除标签\n      noTranslator: 禁用自动翻译\n      iterations: 设置绘制次数\n    messages:\n      exceed-max-iteration: 超过最大绘制次数。\n      expect-prompt: 请输入标签。\n      expect-image: 请输入图片。\n      latin-only: 只接受英文输入。\n      too-many-words: 输入的单词数量过多。\n      forbidden-word: 输入含有违禁词。\n      concurrent-jobs: |-\n        <random>\n        <>等会再约稿吧，我已经忙不过来了……</>\n        <>是数位板没电了，才…才不是我不想画呢！</>\n        <>那你得先教我画画（理直气壮</>\n        </random>\n      waiting: |-\n        <random>\n        <>少女绘画中……</>\n        <>在画了在画了</>\n        <>你就在此地不要走动，等我给你画一幅</>\n        </random>\n      pending: 在画了在画了，不过前面还有 {0} 个稿……\n      invalid-size: 增强功能仅适用于被生成的图片。普通的 img2img 请直接使用「约稿」而不是「增强」。\n      invalid-resolution: 非法的图片尺寸。宽高必须都为 64 的倍数。\n      custom-resolution-unsupported: 不支持自定义图片尺寸。\n      file-too-large: 文件体积过大。\n      unsupported-file-type: 不支持的文件格式。\n      download-error: 图片解析失败。\n      unknown-error: 发生未知错误。\n      response-error: 发生未知错误 ({0})。\n      empty-response: 服务器返回了空白图片，请稍后重试。\n      request-failed: 请求失败 ({0})，请稍后重试。\n      request-timeout: 请求超时。\n      invalid-password: 邮箱或密码错误。\n      invalid-token: 令牌无效或已过期，请联系管理员。\n      unauthorized: 令牌未授权，可能需要续费，请联系管理员。\n  novelai.upscale:\n    description: AI 放大图片\n    options:\n      scale: 设置放大倍数\n      resolution: 设定放大尺寸\n      crop: 是否裁剪以适应尺寸\n      upscaler: 设置放大模型\n      upscaler2: 设置放大模型 2\n      upscaler2visibility: 设置放大模型 2 的可见度\n      upscaleFirst: 先放大再执行面部修复\n    messages:\n      expect-image: 请输入图片。\n      download-error: 图片解析失败。\n      unknown-error: 发生未知错误。\n"
  },
  {
    "path": "src/locales/en-US.yml",
    "content": "commands:\n  novelai:\n    description: Generate Images from Novel AI\n    usage: |-\n      Enter \"novelai\" with English prompt or tags, e.g. a girl in the forest, blonde hair, red eyes, white dress, etc.\n      You can also use comma separated tags like those on Danbooru.\n      Star it: https://github.com/koishijs/novelai-bot\n    options:\n      enhance: Image Enhance Mode\n      model: Set Model for Generation\n      resolution: Set Image Resolution\n      override: Disable Default Prompts\n      sampler: Set Sampler\n      seed: Set Random Seed\n      steps: Set Iteration Steps\n      scale: Set CFG Scale\n      strength: Set Denoising Strength\n      noise: Set Noising Strength\n      hiresFix: Enable Hires Fix.\n      undesired: Negative Prompt\n      noTranslator: Disable Auto Translation\n      iterations: Set Batch Count.\n    messages:\n      exceed-max-iteration: Exceeded max batch count.\n      expect-prompt: Expect a prompt.\n      expect-image: Expect an image.\n      latin-only: Invalid prompt, only English words can be used.\n      too-many-words: Too many words in prompt.\n      forbidden-word: Forbidden words in prompt.\n      concurrent-jobs: |-\n        <random>\n          <>Too busy to handle your request...</>\n          <>Brb power nap :zzz:</>\n          <>(*~*) Have no time to draw a new one.</>\n        </random>\n      waiting: |-\n        <random>\n          <>The illustrator starts painting.</>\n          <>Monet and Da Vinci, whose style is better for this?</>\n        </random>\n      pending: \"<plural count={0}>\\n  <>Sure.</>\\n  <>Sure, but please wait for me to complete this one before.</>\\n  <>Bruh, there are {0} jobs pending!</>\\n</plural>\"\n      invalid-size: The Enhance mode can only be used for images generated. Use \"novelai\" without enhance option if you are using normal img2img.\n      invalid-resolution: Invalid resolution for image generation. The width and height of image should be multiple of 64.\n      custom-resolution-unsupported: Custom resolution is not supported.\n      file-too-large: File is too large.\n      unsupported-file-type: Unsupported file type.\n      download-error: Parsing image failed.\n      unknown-error: An unknown error occurred.\n      response-error: An unknown error occurred ({0}).\n      empty-response: The server didn't return a valid image.\n      request-failed: Request failed ({0}).\n      request-timeout: Request timeout.\n      invalid-password: Incorrect email address or password.\n      invalid-token: The token is invalid or expired. Please contact your administrator.\n      unauthorized: The token is unauthorized, this happens while your account didn't have a valid subscription. Please contact your administrator.\n  novelai.upscale:\n    description: Upscale Images by AI\n    options:\n      scale: Set Upscale By\n      resolution: Set Upscale To\n      crop: Crop Image Before Upscaling\n      upscaler: Set Upscaler\n      upscaler2: Set Upscaler\n      upscaler2visibility: Set Visibility of Upscaler 2\n      upscaleFirst: Upscale Image Before Restoring Face\n    messages:\n      expect-image: Expect an image.\n      download-error: Parsing image failed.\n      unknown-error: An unknown error occurred.\n"
  },
  {
    "path": "src/locales/fr-FR.yml",
    "content": "commands:\n  novelai:\n    description: Générer des images sur IA\n    usage: |-\n      Entrez « novelai » avec les descriptions textuelles (anglais : prompt) de la scène que vous souhaitez générer.\n      De nombreux modèles exigent que les descriptions textuelles soient en anglais, par ex. a girl in the forest, blonde hair, red eyes, white dress.\n      Vous pouvez utiliser des balises séparées par des virgules, comme sur Danbooru.\n      Donnez-lui une étoile : https://github.com/koishijs/novelai-bot\n    options:\n      enhance: Mode d'amélioration de l'image\n      model: Définir le modèle pour la génération\n      resolution: Définir la taille de l'image\n      override: Remplacer les descriptions textuelles de base\n      sampler: Définir l'échantillonneur\n      seed: Définir la graine aléatoire\n      steps: Définir les étapes de l'itération\n      scale: Définir CFG Scale\n      strength: Définir l'intensité du débruitage\n      noise: Définir l'intensité du bruit\n      hiresFix: Activer la correction pour la résolution haute.\n      undesired: Définir les descriptions textuelles négatives\n      noTranslator: Désactiver la traduction automatique\n      iterations: Définir le nombre des générations\n    messages:\n      exceed-max-iteration: Trop du nombre des générations.\n      expect-prompt: Attendrez-vous les descriptions textuelles valides.\n      expect-image: Attendrez-vous une image.\n      latin-only: Les descriptions textuelles ne sont pas valides, vous ne pouvez utiliser que des mots anglais.\n      too-many-words: Trop de mots saisis.\n      forbidden-word: Les descriptions textuelles contiennent des mots prohibés.\n      concurrent-jobs: |-\n        <random>\n          <>Trop occupé pour répondre à votre demande...</>\n          <>Courte sieste :zzz:</>\n          <>(*~*) Pas le temps d'en dessiner un nouveau.</>\n        </random>\n      waiting: |-\n        <random>\n          <>D'accord. Je dessine de belles images pour vous.</>\n          <>Votre demande est en cours de génération, veuillez attendre un moment.</>\n          <>L'illustrateur commence à peindre.</>\n          <>Monet et Da Vinci, quel style convient le mieux à cette image ?</>\n        </random>\n      pending: \"<plural count={0}>\\n  <>D'accord.</>\\n  <>D'accord, mais attendez que je complète la dernière demande.</>\\n  <>>_< Il y a {0} travaux en cours.</>\\n</plural>\"\n      invalid-size: Le mode d'amélioration de l'image peut être utilisé seulement pour les images générées. Si vous utilisez le mode de img2img, utilisez « novelai » sans l'option « --enhance ».\n      invalid-resolution: La taille de l'image n'est pas valide. La largeur et la hauteur de l'image doivent être des multiples de 64.\n      custom-resolution-unsupported: La personnalisation de la résolution n'est pas prise en charge.\n      file-too-large: Le fichier est trop important.\n      unsupported-file-type: Le format de fichier non reconnu.\n      download-error: Une erreur d'analyse syntaxique de l'image s'est produite.\n      unknown-error: Une erreur inconnue s'est produite.\n      response-error: 'Une erreur inconnue s''est produite : ({0}).'\n      empty-response: Le serveur répond avec l'image invalide.\n      request-failed: 'La demande a échoué : ({0}).'\n      request-timeout: Le délai d'attente de la demande dépassé.\n      invalid-password: L'adresse électronique ou mot de passe introduit est incorrect.\n      invalid-token: Le token est invalide ou a expiré. Veuillez contacter l'administrateur.\n      unauthorized: Le token n'est pas autorisé, peut-être que ce token n'a pas d'abonnement valide. Veuillez contacter l'administrateur.\n  novelai.upscale:\n    description: Agrandir des images sur IA\n    options:\n      scale: Mise à l'échelle de\n      resolution: Mise à l'échelle à\n      crop: Recadrer à la taille avant de l'agrandissement.\n      upscaler: Définir l'agrandisseur\n      upscaler2: Définir l'agrandisseur 2\n      upscaler2visibility: Définir la visibilité de l'agrandisseur 2\n      upscaleFirst: Agrandir les images avant de restaurer les visages\n    messages:\n      expect-image: Attendrez-vous une image.\n      download-error: Une erreur d'analyse syntaxique de l'image s'est produite.\n      unknown-error: Une erreur inconnue s'est produite.\n"
  },
  {
    "path": "src/locales/ja-JP.yml",
    "content": "commands:\n  novelai:\n    description: AI で絵を描く\n    usage: |-\n      コンマで区切られた英語の生成呪文 (プロンプト) を入力してください。例：1girl, red eyes, black hair。\n      モデルに用いられる単語は Danbooru のタグとほとんど同じです。\n      興味があったら、レポジトリにスターを付けてください：https://github.com/koishijs/novelai-bot\n    options:\n      enhance: 向上 (enhance) モードを有効\n      model: モデルを指定\n      resolution: 画像解像度を設定\n      override: デフォルトプロンプトを無効にする\n      sampler: サンプラーを指定\n      seed: シード値を設定\n      steps: ステップ数を設定\n      scale: CFG スケール値を設定\n      strength: ノイズ除去強度を設定\n      noise: ノイズ強度を設定\n      hiresFix: 高解像度修正を有効\n      undesired: 反対呪文 (ネガティブプロンプト) を設定\n      noTranslator: 自動翻訳を無効\n      iterations: 画像生成数を設定\n    messages:\n      exceed-max-iteration: 画像生成数が最大に超えました。\n      expect-prompt: 生成呪文を入力してください。\n      expect-image: 画像を入力してください。\n      latin-only: 英数字だけが入力可能です。\n      too-many-words: 入力した単語が多すぎる。\n      forbidden-word: 一部の入力した単語が禁止されている。\n      concurrent-jobs: |-\n        <random>\n          <>後でね～今、猫の手も借りたいなの！</>\n          <>描けるの、た、タブレットが起動できませんだから。</>\n          <>じゃ、まず絵を教えて。</>\n        </random>\n      waiting: |-\n        <random>\n          <>私はプロ絵師だから、どんな絵でも描けるの。</>\n          <>仕事している…</>\n        </random>\n      pending: 仕事している前に {0} つの絵が完遂するべきです。\n      invalid-size: 向上モードは AI 生成画像のみに用いられる。img2img (指定画像から生成) を使いたければ、「--enhance」を追加せずにコマンドを再実行してください。\n      invalid-resolution: 無効な解像度。幅と高さが 64 の倍数である必要があります。\n      custom-resolution-unsupported: カスタム画像解像度は使用できません。\n      file-too-large: ファイルのサイズが大きすぎる。\n      unsupported-file-type: ファイルのタイプがサポートされていません。\n      download-error: 画像のダウンロードに失敗しました。\n      unknown-error: 不明なエラーが発生しました。\n      response-error: 不明なエラーが発生しました ({0})。\n      empty-response: サーバーが無効な画像を返されました、後で試してください。\n      request-failed: リクエストが失敗しました ({0})，後で試してください。\n      request-timeout: リクエストがタイムアウトしました。\n      invalid-password: メールアドレスやパスワードが間違っています。\n      invalid-token: 期間切れたまたは無効なトークンです。管理者に連絡してください。\n      unauthorized: アカウント契約が期間切れるか、トークンが認可されていません。管理者に連絡してください。\n  novelai.upscale:\n    description: AI で画像拡大\n    options:\n      scale: 拡大倍率を設定\n      resolution: 拡大目標解像度を設定\n      crop: 拡大する前に画像をクロップする\n      upscaler: 拡大モデルを設定\n      upscaler2: 拡大モデル２を設定\n      upscaler2visibility: 拡大モデル２の可視度を設定\n      upscaleFirst: 拡大する前にフェイス修正を行う\n    messages:\n      expect-image: 画像を入力してください。\n      download-error: 画像のダウンロードに失敗しました。\n      unknown-error: 不明なエラーが発生しました。\n"
  },
  {
    "path": "src/locales/zh-CN.yml",
    "content": "commands:\n  novelai:\n    description: AI 画图\n    usage: |-\n      输入用逗号隔开的英文标签，例如 Mr.Quin, dark sword, red eyes。\n      查找标签可以使用 Danbooru。\n      快来给仓库点个 star 吧：https://github.com/koishijs/novelai-bot\n\n    shortcuts:\n      imagine: 画画|约稿\n      enhance: 增强\n\n    options:\n      enhance: 图片增强模式\n      model: 设定生成模型\n      resolution: 设定图片尺寸\n      override: 禁用默认标签\n      sampler: 设置采样器\n      seed: 设置随机种子\n      steps: 设置迭代步数\n      scale: 设置对输入的服从度\n      strength: 图片修改幅度\n      noise: 图片噪声强度\n      hiresFix: 启用高分辨率修复\n      undesired: 排除标签\n      noTranslator: 禁用自动翻译\n      iterations: 设置绘制次数\n      batch: 设置绘制批次大小\n      smea: 启用 SMEA\n      smeaDyn: 启用 DYN\n      scheduler: 设置调度器\n      decrisper: 启用动态阈值\n\n    messages:\n      exceed-max-iteration: 超过最大绘制次数。\n      expect-prompt: 请输入标签。\n      expect-image: 请输入图片。\n      too-many-images: 过多的图片。\n      invalid-content: 输入中含有无效内容。\n      latin-only: 只接受英文输入。\n      too-many-words: 输入的单词数量过多。\n      forbidden-word: 输入含有违禁词。\n      concurrent-jobs: |-\n        <random>\n          <>等会再约稿吧，我已经忙不过来了……</>\n          <>是数位板没电了，才…才不是我不想画呢！</>\n          <>那你得先教我画画（理直气壮</>\n        </random>\n      waiting: |-\n        <random>\n          <>少女绘画中……</>\n          <>在画了在画了</>\n          <>你就在此地不要走动，等我给你画一幅</>\n        </random>\n      pending: 在画了在画了，不过前面还有 {0} 个稿……\n      invalid-size: 增强功能仅适用于被生成的图片。普通的 img2img 请直接使用「约稿」而不是「增强」。\n      invalid-resolution: 非法的图片尺寸。宽高必须都为 64 的倍数。\n      custom-resolution-unsupported: 不支持自定义图片尺寸。\n      file-too-large: 文件体积过大。\n      unsupported-file-type: 不支持的文件格式。\n      download-error: 图片解析失败。\n      unknown-error: 发生未知错误。\n      response-error: 发生未知错误 ({0})。\n      empty-response: 服务器返回了空白图片，请稍后重试。\n      request-failed: 请求失败 ({0})，请稍后重试。\n      request-timeout: 请求超时。\n      invalid-password: 邮箱或密码错误。\n      invalid-token: 令牌无效或已过期，请联系管理员。\n      unauthorized: 令牌未授权，可能需要续费，请联系管理员。\n\n  novelai.upscale:\n    description: AI 放大图片\n\n    shortcuts:\n      upscale: 放大\n\n    options:\n      scale: 设置放大倍数\n      resolution: 设定放大尺寸\n      crop: 是否裁剪以适应尺寸\n      upscaler: 设置放大模型\n      upscaler2: 设置放大模型 2\n      upscaler2visibility: 设置放大模型 2 的可见度\n      upscaleFirst: 先放大再执行面部修复\n\n    messages:\n      expect-image: 请输入图片。\n      download-error: 图片解析失败。\n      unknown-error: 发生未知错误。\n"
  },
  {
    "path": "src/locales/zh-TW.yml",
    "content": "commands:\n  novelai:\n    description: AI 繪圖\n    usage: |-\n      輸入以逗號分割的英文提示詞，例如 portrait, blonde hair, red eyes。\n      查找可用的提示詞標籤可以使用 Danbooru。\n      快來給專案標星收藏吧：https://github.com/koishijs/novelai-bot\n    shortcuts:\n      imagine: 畫畫|約稿\n      enhance: 增強\n    options:\n      enhance: 圖像增強模式\n      model: 設定生成模型\n      resolution: 設定圖像尺寸\n      override: 禁用預設標籤\n      sampler: 設定採樣器\n      seed: 設定隨機種子\n      steps: 設定迭代步數\n      scale: 設定提示詞的相關性\n      strength: 圖像修改幅度\n      noise: 圖像雜訊強度\n      hiresFix: 啟用高分辨率修復\n      undesired: 反向提示詞\n      noTranslator: 禁用自動翻譯\n      iterations: 設定繪畫次數\n    messages:\n      exceed-max-iteration: 超過最大繪畫次數\n      expect-prompt: 請輸入提示詞。\n      expect-image: 請輸入圖像。\n      latin-only: 僅接受英文提示詞。\n      too-many-words: 輸入的提示詞數量過多\n      forbidden-word: 提示詞中含有違禁詞彙。\n      concurrent-jobs: |-\n        <random>\n          <>等下再畫吧，我已經忙不過來了……</>\n          <>我…我纔不是不會畫畫，只是沒時間！</>\n          <>我先喝杯咖啡可以嗎，好睏～</>\n        </random>\n      waiting: |-\n        <random>\n          <>少女繪畫中</>\n          <>莫行開，我即時來畫！</>\n        </random>\n      pending: 好酒沉甕底。您還需等我完成前面 {0} 個稿件。\n      invalid-size: 增強功能僅適用於 Novel AI 生成圖。若要使用 img2img 功能請直接使用「約稿」而非「增強」。\n      invalid-resolution: 圖像尺寸無效。寬度與高度都須爲 64 的倍數。\n      custom-resolution-unsupported: 不支援自訂圖像尺寸。\n      file-too-large: 文件體積過大。\n      unsupported-file-type: 不支援的檔案格式。\n      download-error: 圖像解析失敗。\n      unknown-error: 發生未知的錯誤。\n      response-error: 發生未知的錯誤 ({0})。\n      empty-response: 伺服器返回了空圖像，請稍後重試。\n      request-failed: 擷取資料失敗 ({0})，請稍後重試。\n      request-timeout: 擷取資料超時。\n      invalid-password: 電郵地址或密碼不正確。\n      invalid-token: 令牌無效或已過期，請聯繫管理員。\n      unauthorized: 令牌未經授權，可能關聯帳戶需要續費，請聯繫管理員。\n  novelai.upscale:\n    description: AI 放大圖像\n    shortcuts:\n      upscale: 放大\n    options:\n      scale: 設定放大倍率\n      resolution: 設定放大尺寸\n      crop: 是否裁剪以適應尺寸\n      upscaler: 設定放大模型\n      upscaler2: 設定放大模型 2\n      upscaler2visibility: 設定放大模型 2 的可視度\n      upscaleFirst: 先放大再執行面部修復\n    messages:\n      expect-image: 請輸入圖像。\n      download-error: 圖像解析失敗。\n      unknown-error: 發生未知的錯誤。\n"
  },
  {
    "path": "src/types.ts",
    "content": "export interface Perks {\n  maxPriorityActions: number\n  startPriority: number\n  contextTokens: number\n  moduleTrainingSteps: number\n  unlimitedMaxPriority: boolean\n  voiceGeneration: boolean\n  imageGeneration: boolean\n  unlimitedImageGeneration: boolean\n  unlimitedImageGenerationLimits: {\n    resolution: number\n    maxPrompts: number\n  }[]\n}\n\nexport interface PaymentProcessorData {\n  c: string\n  n: number\n  o: string\n  p: number\n  r: string\n  s: string\n  t: number\n  u: string\n}\n\nexport interface TrainingStepsLeft {\n  fixedTrainingStepsLeft: number\n  purchasedTrainingSteps: number\n}\n\nexport interface Subscription {\n  tier: number\n  active: boolean\n  expiresAt: number\n  perks: Perks\n  paymentProcessorData: PaymentProcessorData\n  trainingStepsLeft: TrainingStepsLeft\n}\n\nexport interface ImageData {\n  buffer: ArrayBuffer\n  base64: string\n  dataUrl: string\n}\n\nexport namespace NovelAI {\n  /** 0.5, 0.5 means make ai choose */\n  export interface V4CharacterPromptCenter {\n    x: number\n    y: number\n  }\n\n  export interface V4CharacterPrompt {\n    prompt: string\n    uc: string\n    center: V4CharacterPromptCenter\n  }\n\n  export interface V4CharCaption {\n    char_caption: string\n    centers: V4CharacterPromptCenter[]\n  }\n\n  export interface V4PromptCaption {\n    base_caption: string\n    char_captions: V4CharCaption[]\n  }\n\n  export interface V4Prompt {\n    caption: V4PromptCaption\n  }\n\n  export interface V4PromptPositive extends V4Prompt {\n    use_coords: boolean\n    use_order: boolean\n  }\n}\n\nexport namespace StableDiffusionWebUI {\n  export interface Request {\n    prompt: string\n    negative_prompt?: string\n    enable_hr?: boolean\n    denoising_strength?: number\n    firstphase_width?: number\n    firstphase_height?: number\n    styles?: string[]\n    seed?: number\n    subseed?: number\n    subseed_strength?: number\n    seed_resize_from_h?: number\n    seed_resize_from_w?: number\n    batch_size?: number\n    n_iter?: number\n    steps?: number\n    cfg_scale?: number\n    width?: number\n    height?: number\n    restore_faces?: boolean\n    tiling?: boolean\n    eta?: number\n    s_churn?: number\n    s_tmax?: number\n    s_tmin?: number\n    s_noise?: number\n    sampler_index?: string\n  }\n\n  export interface Response {\n    /** Image list in base64 format */\n    images: string[]\n    parameters: any\n    info: any\n  }\n\n  /**\n   * @see https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/828438b4a190759807f9054932cae3a8b880ddf1/modules/api/models.py#L122\n   */\n  export interface ExtraSingleImageRequest {\n    image: string\n    /** Sets the resize mode: 0 to upscale by upscaling_resize amount, 1 to upscale up to upscaling_resize_h x upscaling_resize_w. */\n    resize_mode?: 0 | 1\n    show_extras_results?: boolean\n    gfpgan_visibility?: number // float\n    codeformer_visibility?: number // float\n    codeformer_weight?: number // float\n    upscaling_resize?: number // float\n    upscaling_resize_w?: number // int\n    upscaling_resize_h?: number // int\n    upscaling_crop?: boolean\n    upscaler_1?: string\n    upscaler_2?: string\n    extras_upscaler_2_visibility?: number // float\n    upscale_first?: boolean\n  }\n\n  export interface ExtraSingleImageResponse {\n    image: string\n  }\n}\n"
  },
  {
    "path": "src/utils.ts",
    "content": "import { arrayBufferToBase64, Context, Dict, pick, Quester } from 'koishi'\nimport {\n  crypto_generichash, crypto_pwhash,\n  crypto_pwhash_ALG_ARGON2ID13, crypto_pwhash_SALTBYTES, ready,\n} from 'libsodium-wrappers-sumo'\nimport imageSize from 'image-size'\nimport { ImageData, Subscription } from './types'\n\nexport function project(object: {}, mapping: {}) {\n  const result = {}\n  for (const key in mapping) {\n    result[key] = object[mapping[key]]\n  }\n  return result\n}\n\nexport interface Size {\n  width: number\n  height: number\n}\n\nexport function getImageSize(buffer: ArrayBuffer): Size {\n  if (typeof Buffer !== 'undefined') {\n    return imageSize(new Uint8Array(buffer))\n  }\n  const blob = new Blob([buffer])\n  const image = new Image()\n  image.src = URL.createObjectURL(blob)\n  return pick(image, ['width', 'height'])\n}\n\nconst MAX_OUTPUT_SIZE = 1048576\nconst MAX_CONTENT_SIZE = 10485760\nconst ALLOWED_TYPES = ['image/jpeg', 'image/png']\n\nexport async function download(ctx: Context, url: string, headers = {}): Promise<ImageData> {\n  if (url.startsWith('data:') || url.startsWith('file:')) {\n    const { mime, data } = await ctx.http.file(url)\n    if (!ALLOWED_TYPES.includes(mime)) {\n      throw new NetworkError('.unsupported-file-type')\n    }\n    const base64 = arrayBufferToBase64(data)\n    return { buffer: data, base64, dataUrl: `data:${mime};base64,${base64}` }\n  } else {\n    const image = await ctx.http(url, { responseType: 'arraybuffer', headers })\n    if (+image.headers.get('content-length') > MAX_CONTENT_SIZE) {\n      throw new NetworkError('.file-too-large')\n    }\n    const mimetype = image.headers.get('content-type')\n    if (!ALLOWED_TYPES.includes(mimetype)) {\n      throw new NetworkError('.unsupported-file-type')\n    }\n    const buffer = image.data\n    const base64 = arrayBufferToBase64(buffer)\n    return { buffer, base64, dataUrl: `data:${mimetype};base64,${base64}` }\n  }\n}\n\nexport async function calcAccessKey(email: string, password: string) {\n  await ready\n  return crypto_pwhash(\n    64,\n    new Uint8Array(Buffer.from(password)),\n    crypto_generichash(\n      crypto_pwhash_SALTBYTES,\n      password.slice(0, 6) + email + 'novelai_data_access_key',\n    ),\n    2,\n    2e6,\n    crypto_pwhash_ALG_ARGON2ID13,\n    'base64').slice(0, 64)\n}\n\nexport async function calcEncryptionKey(email: string, password: string) {\n  await ready\n  return crypto_pwhash(\n    128,\n    new Uint8Array(Buffer.from(password)),\n    crypto_generichash(\n      crypto_pwhash_SALTBYTES,\n      password.slice(0, 6) + email + 'novelai_data_encryption_key'),\n    2,\n    2e6,\n    crypto_pwhash_ALG_ARGON2ID13,\n    'base64')\n}\n\nexport class NetworkError extends Error {\n  constructor(message: string, public params = {}) {\n    super(message)\n  }\n\n  static catch = (mapping: Dict<string>) => (e: any) => {\n    if (Quester.Error.is(e)) {\n      const code = e.response?.status\n      for (const key in mapping) {\n        if (code === +key) {\n          throw new NetworkError(mapping[key])\n        }\n      }\n    }\n    throw e\n  }\n}\n\nexport async function login(ctx: Context): Promise<string> {\n  if (ctx.config.type === 'token') {\n    await ctx.http.get<Subscription>(ctx.config.apiEndpoint + '/user/subscription', {\n      timeout: 30000,\n      headers: { authorization: 'Bearer ' + ctx.config.token },\n    }).catch(NetworkError.catch({ 401: '.invalid-token' }))\n    return ctx.config.token\n  } else if (ctx.config.type === 'login' && process.env.KOISHI_ENV !== 'browser') {\n    return ctx.http.post(ctx.config.apiEndpoint + '/user/login', {\n      timeout: 30000,\n      key: await calcAccessKey(ctx.config.email, ctx.config.password),\n    }).catch(NetworkError.catch({ 401: '.invalid-password' })).then(res => res.accessToken)\n  } else {\n    return ctx.config.token\n  }\n}\n\nexport function closestMultiple(num: number, mult = 64) {\n  const floor = Math.floor(num / mult) * mult\n  const ceil = Math.ceil(num / mult) * mult\n  const closest = num - floor < ceil - num ? floor : ceil\n  if (Number.isNaN(closest)) return 0\n  return closest <= 0 ? mult : closest\n}\n\nexport interface Size {\n  width: number\n  height: number\n  /** Indicate whether this resolution is pre-defined or customized */\n  custom?: boolean\n}\n\nexport function resizeInput(size: Size): Size {\n  // if width and height produce a valid size, use it\n  const { width, height } = size\n  if (width % 64 === 0 && height % 64 === 0 && width * height <= MAX_OUTPUT_SIZE) {\n    return { width, height }\n  }\n\n  // otherwise, set lower size as 512 and use aspect ratio to the other dimension\n  const aspectRatio = width / height\n  if (aspectRatio > 1) {\n    const height = 512\n    const width = closestMultiple(height * aspectRatio)\n    // check that image is not too large\n    if (width * height <= MAX_OUTPUT_SIZE) {\n      return { width, height }\n    }\n  } else {\n    const width = 512\n    const height = closestMultiple(width / aspectRatio)\n    // check that image is not too large\n    if (width * height <= MAX_OUTPUT_SIZE) {\n      return { width, height }\n    }\n  }\n\n  // if that fails set the higher size as 1024 and use aspect ratio to the other dimension\n  if (aspectRatio > 1) {\n    const width = 1024\n    const height = closestMultiple(width / aspectRatio)\n    return { width, height }\n  } else {\n    const height = 1024\n    const width = closestMultiple(height * aspectRatio)\n    return { width, height }\n  }\n}\n\nexport function forceDataPrefix(url: string, mime = 'image/png') {\n  // workaround for different gradio versions\n  // https://github.com/koishijs/novelai-bot/issues/90\n  if (url.startsWith('data:')) return url\n  return `data:${mime};base64,` + url\n}\n"
  },
  {
    "path": "tests/index.spec.ts",
    "content": "import { describe, test } from 'node:test'\nimport * as novelai from '../src'\nimport { Context } from 'koishi'\nimport mock from '@koishijs/plugin-mock'\n\ndescribe('koishi-plugin-novelai', () => {\n  test('parse input', () => {\n    const ctx = new Context()\n    ctx.plugin(mock)\n    const session = ctx.bots[0].session({})\n    const fork = ctx.plugin(novelai)\n    console.log(novelai.parseInput(session, '<lora:skr2:1>,1girl', fork.config, false))\n  })\n})\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"lib\",\n    \"target\": \"es2022\",\n    \"module\": \"esnext\",\n    \"declaration\": true,\n    \"emitDeclarationOnly\": true,\n    \"composite\": true,\n    \"incremental\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"moduleResolution\": \"bundler\"\n  },\n  \"include\": [\n    \"src\"\n  ]\n}"
  },
  {
    "path": "vercel.json",
    "content": "{\n  \"github\": {\n    \"silent\": true\n  }\n}\n"
  }
]