Full Code of koishijs/novelai-bot for AI

main 50667895bf74 cached
40 files
108.6 KB
34.8k tokens
51 symbols
1 requests
Download .txt
Repository: koishijs/novelai-bot
Branch: main
Commit: 50667895bf74
Files: 40
Total size: 108.6 KB

Directory structure:
gitextract_j9990osm/

├── .editorconfig
├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.yaml
│   │   ├── config.yaml
│   │   └── feature-request.yaml
│   └── workflows/
│       ├── build.yml
│       └── stale.yml
├── .gitignore
├── LICENSE
├── build/
│   ├── fetch-horde-models.js
│   └── fetch-sd-samplers.js
├── crowdin.yml
├── data/
│   ├── default-comfyui-i2i-wf.json
│   ├── default-comfyui-t2i-wf.json
│   ├── horde-models.json
│   └── sd-samplers.json
├── docs/
│   ├── .vitepress/
│   │   ├── config.ts
│   │   └── theme/
│   │       └── index.ts
│   ├── config.md
│   ├── faq/
│   │   ├── adapter.md
│   │   └── network.md
│   ├── index.md
│   ├── more.md
│   ├── public/
│   │   └── manifest.json
│   └── usage.md
├── package.json
├── readme.md
├── src/
│   ├── config.ts
│   ├── index.ts
│   ├── locales/
│   │   ├── de-DE.yml
│   │   ├── en-US.yml
│   │   ├── fr-FR.yml
│   │   ├── ja-JP.yml
│   │   ├── zh-CN.yml
│   │   └── zh-TW.yml
│   ├── types.ts
│   └── utils.ts
├── tests/
│   └── index.spec.ts
├── tsconfig.json
└── vercel.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*]
insert_final_newline = true
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true


================================================
FILE: .gitattributes
================================================
* text eol=lf

*.png -text
*.jpg -text
*.ico -text
*.gif -text
*.webp -text


================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yaml
================================================
name: Bug Report
description: 提交错误报告
title: "Bug: "
labels:
  - bug
body:
  - type: textarea
    attributes:
      label: Describe the bug
      description: 请简明地表述 bug 是什么。
    validations:
      required: true
  - type: textarea
    attributes:
      label: Steps to reproduce
      description: 请描述如何重现这个行为。
    validations:
      required: true
  - type: textarea
    attributes:
      label: Expected behavior
      description: 请描述期望的行为。
    validations:
      required: true
  - type: textarea
    attributes:
      label: Screenshots
      description: 请尽量详细地提供相关截图,可以是聊天记录、Koishi 日志和服务端 (如 go-cqhttp) 日志等。文本的日志请复制到下方文本框。
  - type: textarea
    id: logs
    attributes:
      label: Relevant log output
      description: 请将日志输出复制到此处。这个文本框的内容会自动渲染为代码,因此你不需要添加反引号 (`)。
      render: shell
  - type: dropdown
    id: launcher
    attributes:
      label: Launcher
      description: 启动 Koishi 的方式
      options:
        - Koishi Desktop
        - Koishi Android
        - Koishi CLI (koishi start)
        - Containers (Docker, Kubernates, etc)
        - Manually (node index.js)
    validations:
      required: true
  - type: dropdown
    id: backend
    attributes:
      label: Backend
      description: 服务类型及登录方式
      options:
        - NovelAI (帐号密码)
        - NovelAI (Token)
        - NAIFU
        - Stable Diffusion WebUI (AUTOMATIC1111)
        - Stable Horde
        - Others
    validations:
      required: true
  - type: textarea
    attributes:
      label: Versions
      description: 请填写相应的版本号。
      value: |
        - OS: <!-- e.g. Windows 10 -->
        - Adapter: <!-- e.g. Discord -->
        - Node version: <!-- e.g. 18.12.0 -->
        - Koishi version: <!-- e.g. 4.9.9 -->
    validations:
      required: true
  - type: textarea
    attributes:
      label: Additional context
      description: 请描述其他想要补充的信息。


================================================
FILE: .github/ISSUE_TEMPLATE/config.yaml
================================================
blank_issues_enabled: false
contact_links:
  - name: Discussions
    url: https://github.com/koishijs/novelai-bot/discussions
    about: 相关话题、分享想法、询问问题,尽情地在讨论中灌水!


================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.yaml
================================================
name: Feature Request
description: 提交功能请求
title: "Feature: "
labels:
  - feature
body:
  - type: dropdown
    id: scope
    attributes:
      label: Scope
      description: 该特性适用的后端
      multiple: true
      options:
        - NovelAI
        - NAIFU
        - Stable Diffusion WebUI (AUTOMATIC1111)
        - Stable Horde
        - Others
    validations:
      required: true
  - type: textarea
    attributes:
      label: Describe the problem related to the feature request
      description: 请简要地说明是什么问题导致你想要一个新特性。
    validations:
      required: true
  - type: textarea
    attributes:
      label: Describe the solution you'd like
      description: 请说明你希望使用什么样的方法 (比如增加什么功能) 解决上述问题。
    validations:
      required: true
  - type: textarea
    attributes:
      label: Describe alternatives you've considered
      description: 除了上述方法以外,你还考虑过哪些其他的实现方式?
  - type: textarea
    attributes:
      label: Additional context
      description: 请描述其他想要补充的信息。


================================================
FILE: .github/workflows/build.yml
================================================
name: Build

on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Check out
        uses: actions/checkout@v4
      - name: Setup Node
        uses: actions/setup-node@v4
      - name: Install
        run: yarn
      - name: Build
        run: yarn build


================================================
FILE: .github/workflows/stale.yml
================================================
name: Stale

on:
  schedule:
    - cron: 30 7 * * *

jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v4
        with:
          stale-issue-label: stale
          stale-issue-message: |
            This issue is stale because it has been open 15 days with no activity.
            Remove stale label or comment or this will be closed in 5 days.
          close-issue-message: |
            This issue was closed because it has been stalled for 5 days with no activity.
          days-before-issue-stale: 15
          days-before-issue-close: 5
          any-of-labels: awaiting feedback, invalid


================================================
FILE: .gitignore
================================================
lib
dist
cache

node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
tsconfig.tsbuildinfo

.eslintcache
.DS_Store
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2020-present Shigma & Ninzore

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: build/fetch-horde-models.js
================================================
const fsp = require('fs/promises')
const https = require('https')
const path = require('path')

const MODELS_URL = 'https://stablehorde.net/api/v2/status/models'
const DATA_JSON_PATH = path.join(__dirname, '..', 'data', 'horde-models.json')

;(async () => {
  const db = await new Promise((resolve, reject) => {
    https.get(MODELS_URL, res => {
      let data = ''
      res.on('data', chunk => data += chunk)
      res.on('end', () => resolve(JSON.parse(data)))
    }).on('error', reject)
  })

  const models = db.map((model) => model.name)

  const json = JSON.stringify(models, null, 2)
  await fsp.writeFile(DATA_JSON_PATH, json + '\n')
})()


================================================
FILE: build/fetch-sd-samplers.js
================================================
const fsp = require('fs/promises')
const http = require('http')
const path = require('path')

const API_ROOT = process.argv[2] || 'http://localhost:7860'
const SAMPLERS_ENDPOINT = '/sdapi/v1/samplers'
const DATA_JSON_PATH = path.join(__dirname, '..', 'data', 'sd-samplers.json')

;(async () => {
  const r = await new Promise((resolve, reject) => {
    http.get(API_ROOT + SAMPLERS_ENDPOINT, res => {
      let data = ''
      res.on('data', chunk => data += chunk)
      res.on('end', () => resolve(JSON.parse(data)))
    }).on('error', reject)
  })

  const samplers = r.reduce((acc, sampler) => {
    const { name, aliases, options } = sampler
    acc[aliases[0]] = name
    return acc
  }, {})

  const json = JSON.stringify(samplers, null, 2)
  await fsp.writeFile(DATA_JSON_PATH, json + '\n')
})()


================================================
FILE: crowdin.yml
================================================
pull_request_title: 'i18n: update translations'
pull_request_labels:
  - i18n
files:
  - source: /src/locales/zh-CN.yml
    translation: /src/locales/%locale%.yml


================================================
FILE: data/default-comfyui-i2i-wf.json
================================================
{
  "3": {
    "inputs": {
      "seed": 1,
      "steps": 20,
      "cfg": 8,
      "sampler_name": "euler",
      "scheduler": "normal",
      "denoise": 0.87,
      "model": [
        "14",
        0
      ],
      "positive": [
        "6",
        0
      ],
      "negative": [
        "7",
        0
      ],
      "latent_image": [
        "12",
        0
      ]
    },
    "class_type": "KSampler",
    "_meta": {
      "title": "KSampler"
    }
  },
  "6": {
    "inputs": {
      "text": "",
      "clip": [
        "14",
        1
      ]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {
      "title": "CLIP Text Encode (Prompt)"
    }
  },
  "7": {
    "inputs": {
      "text": "",
      "clip": [
        "14",
        1
      ]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {
      "title": "CLIP Text Encode (Prompt)"
    }
  },
  "8": {
    "inputs": {
      "samples": [
        "3",
        0
      ],
      "vae": [
        "14",
        2
      ]
    },
    "class_type": "VAEDecode",
    "_meta": {
      "title": "VAE Decode"
    }
  },
  "9": {
    "inputs": {
      "filename_prefix": "ComfyUI",
      "images": [
        "8",
        0
      ]
    },
    "class_type": "SaveImage",
    "_meta": {
      "title": "Save Image"
    }
  },
  "10": {
    "inputs": {
      "image": "example.png",
      "upload": "image"
    },
    "class_type": "LoadImage",
    "_meta": {
      "title": "Load Image"
    }
  },
  "12": {
    "inputs": {
      "pixels": [
        "10",
        0
      ],
      "vae": [
        "14",
        2
      ]
    },
    "class_type": "VAEEncode",
    "_meta": {
      "title": "VAE Encode"
    }
  },
  "14": {
    "inputs": {
      "ckpt_name": ""
    },
    "class_type": "CheckpointLoaderSimple",
    "_meta": {
      "title": "Load Checkpoint"
    }
  }
}

================================================
FILE: data/default-comfyui-t2i-wf.json
================================================
{
  "3": {
    "inputs": {
      "seed": 1,
      "steps": 20,
      "cfg": 8,
      "sampler_name": "euler",
      "scheduler": "normal",
      "denoise": 0.87,
      "model": [
        "14",
        0
      ],
      "positive": [
        "6",
        0
      ],
      "negative": [
        "7",
        0
      ],
      "latent_image": [
        "16",
        0
      ]
    },
    "class_type": "KSampler",
    "_meta": {
      "title": "KSampler"
    }
  },
  "6": {
    "inputs": {
      "text": "",
      "clip": [
        "14",
        1
      ]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {
      "title": "CLIP Text Encode (Prompt)"
    }
  },
  "7": {
    "inputs": {
      "text": "",
      "clip": [
        "14",
        1
      ]
    },
    "class_type": "CLIPTextEncode",
    "_meta": {
      "title": "CLIP Text Encode (Prompt)"
    }
  },
  "8": {
    "inputs": {
      "samples": [
        "3",
        0
      ],
      "vae": [
        "14",
        2
      ]
    },
    "class_type": "VAEDecode",
    "_meta": {
      "title": "VAE Decode"
    }
  },
  "9": {
    "inputs": {
      "filename_prefix": "ComfyUI",
      "images": [
        "8",
        0
      ]
    },
    "class_type": "SaveImage",
    "_meta": {
      "title": "Save Image"
    }
  },
  "14": {
    "inputs": {
      "ckpt_name": ""
    },
    "class_type": "CheckpointLoaderSimple",
    "_meta": {
      "title": "Load Checkpoint"
    }
  },
  "16": {
    "inputs": {
      "width": 512,
      "height": 800,
      "batch_size": 1
    },
    "class_type": "EmptyLatentImage",
    "_meta": {
      "title": "Empty Latent Image"
    }
  }
}

================================================
FILE: data/horde-models.json
================================================
[
  "3DKX",
  "526Mix-Animated",
  "AAM XL",
  "AbsoluteReality",
  "Abyss OrangeMix",
  "AbyssOrangeMix-AfterDark",
  "ACertainThing",
  "AIO Pixel Art",
  "AlbedoBase XL (SDXL)",
  "AMPonyXL",
  "Analog Diffusion",
  "Analog Madness",
  "Animagine XL",
  "Anime Illust Diffusion XL",
  "Anime Pencil Diffusion",
  "Anygen",
  "AnyLoRA",
  "Anything Diffusion",
  "Anything Diffusion Inpainting",
  "Anything v3",
  "Anything v5",
  "App Icon Diffusion",
  "Art Of Mtg",
  "Aurora",
  "A-Zovya RPG Inpainting",
  "Babes",
  "BB95 Furry Mix",
  "BB95 Furry Mix v14",
  "Blank Canvas XL",
  "BPModel",
  "BRA",
  "BweshMix",
  "CamelliaMix 2.5D",
  "Cetus-Mix",
  "Char",
  "CharHelper",
  "Cheese Daddys Landscape Mix",
  "Cheyenne",
  "ChilloutMix",
  "ChromaV5",
  "Classic Animation Diffusion",
  "Colorful",
  "Comic-Diffusion",
  "Counterfeit",
  "CyberRealistic",
  "CyriousMix",
  "Dan Mumford Style",
  "Dark Sushi Mix",
  "Dark Victorian Diffusion",
  "Deliberate",
  "Deliberate 3.0",
  "Deliberate Inpainting",
  "DGSpitzer Art Diffusion",
  "Disco Elysium",
  "Disney Pixar Cartoon Type A",
  "DnD Item",
  "DnD Map Generator",
  "Double Exposure Diffusion",
  "Dreamlike Diffusion",
  "Dreamlike Photoreal",
  "DreamLikeSamKuvshinov",
  "Dreamshaper",
  "DreamShaper Inpainting",
  "DreamShaper XL",
  "DucHaiten",
  "DucHaiten Classic Anime",
  "Dungeons and Diffusion",
  "Dungeons n Waifus",
  "Edge Of Realism",
  "Eimis Anime Diffusion",
  "Elldreth's Lucid Mix",
  "Elysium Anime",
  "Epic Diffusion",
  "Epic Diffusion Inpainting",
  "Ether Real Mix",
  "Experience",
  "ExpMix Line",
  "FaeTastic",
  "Fantasy Card Diffusion",
  "Fluffusion",
  "Funko Diffusion",
  "Furry Epoch",
  "Fustercluck",
  "Galena Redux",
  "Ghibli Diffusion",
  "GhostMix",
  "GorynichMix",
  "Grapefruit Hentai",
  "Graphic-Art",
  "GTA5 Artwork Diffusion",
  "GuFeng",
  "GuoFeng",
  "HASDX",
  "Hassaku",
  "Hassanblend",
  "Healy's Anime Blend",
  "Henmix Real",
  "Hentai Diffusion",
  "HRL",
  "ICBINP - I Can't Believe It's Not Photography",
  "ICBINP XL",
  "iCoMix",
  "iCoMix Inpainting",
  "Illuminati Diffusion",
  "Inkpunk Diffusion",
  "Jim Eidomode",
  "JoMad Diffusion",
  "Juggernaut XL",
  "JWST Deep Space Diffusion",
  "Kenshi",
  "Laolei New Berry Protogen Mix",
  "Lawlas's yiff mix",
  "Liberty",
  "Lyriel",
  "majicMIX realistic",
  "Mega Merge Diffusion",
  "MeinaMix",
  "Microcritters",
  "Microworlds",
  "Midjourney PaintArt",
  "Mistoon Amethyst",
  "ModernArt Diffusion",
  "Moedel",
  "MoistMix",
  "MoonMix Fantasy",
  "Movie Diffusion",
  "Neurogen",
  "NeverEnding Dream",
  "Nitro Diffusion",
  "OpenJourney Diffusion",
  "Openniji",
  "Papercut Diffusion",
  "Pastel Mix",
  "Perfect World",
  "PFG",
  "Photon",
  "Poison",
  "Pokemon3D",
  "Pony Diffusion XL",
  "PortraitPlus",
  "PPP",
  "Pretty 2.5D",
  "Project Unreal Engine 5",
  "ProtoGen",
  "Protogen Anime",
  "Protogen Infinity",
  "Pulp Vector Art",
  "Quiet Goodnight XL",
  "Ranma Diffusion",
  "RealBiter",
  "Real Dos Mix",
  "Realisian",
  "Realism Engine",
  "Realistic Vision",
  "Realistic Vision Inpainting",
  "Reliberate",
  "Rev Animated",
  "Robo-Diffusion",
  "RPG",
  "Samaritan 3d Cartoon",
  "Sci-Fi Diffusion",
  "SD-Silicon",
  "SDXL 1.0",
  "Seek.art MEGA",
  "Something",
  "Stable Cascade 1.0",
  "stable_diffusion",
  "stable_diffusion_2.1",
  "stable_diffusion_inpainting",
  "SwamPonyXL",
  "SweetBoys 2D",
  "ToonYou",
  "Trinart Characters",
  "Tron Legacy Diffusion",
  "Uhmami",
  "Ultraskin",
  "UMI Olympus",
  "Unstable Diffusers XL",
  "Unstable Ink Dream",
  "URPM",
  "Vector Art",
  "vectorartz",
  "VinteProtogenMix",
  "waifu_diffusion",
  "Western Animation Diffusion",
  "Woop-Woop Photo",
  "Yiffy",
  "Zack3D",
  "Zeipher Female Model"
]


================================================
FILE: data/sd-samplers.json
================================================
{
  "k_dpmpp_2m": "DPM++ 2M",
  "k_dpmpp_sde": "DPM++ SDE",
  "k_dpmpp_2m_sde": "DPM++ 2M SDE",
  "k_dpmpp_2m_sde_heun": "DPM++ 2M SDE Heun",
  "k_dpmpp_2s_a": "DPM++ 2S a",
  "k_dpmpp_3m_sde": "DPM++ 3M SDE",
  "k_euler_a": "Euler a",
  "k_euler": "Euler",
  "k_lms": "LMS",
  "k_heun": "Heun",
  "k_dpm_2": "DPM2",
  "k_dpm_2_a": "DPM2 a",
  "k_dpm_fast": "DPM fast",
  "k_dpm_ad": "DPM adaptive",
  "restart": "Restart",
  "ddim": "DDIM",
  "plms": "PLMS",
  "unipc": "UniPC",
  "k_lcm": "LCM"
}


================================================
FILE: docs/.vitepress/config.ts
================================================
import { defineConfig } from '@cordisjs/vitepress'

export default defineConfig({
  lang: 'zh-CN',
  title: 'NovelAI Bot',
  description: '基于 NovelAI 的画图机器人',

  head: [
    ['link', { rel: 'icon', href: 'https://koishi.chat/logo.png' }],
    ['link', { rel: 'manifest', href: '/manifest.json' }],
    ['meta', { name: 'theme-color', content: '#5546a3' }],
  ],

  themeConfig: {
    nav: [{
      text: '更多',
      items: [{
        text: '关于我们',
        items: [
          { text: 'Koishi 官网', link: 'https://koishi.chat' },
          { text: 'NovelAI.dev', link: 'https://novelai.dev' },
          { text: '支持作者', link: 'https://afdian.net/a/shigma' },
        ]
      }, {
        text: '友情链接',
        items: [
          { text: '法术解析', link: 'https://spell.novelai.dev' },
          { text: '标签超市', link: 'https://tags.novelai.dev' },
          { text: '绘世百科', link: 'https://wiki.novelai.dev' },
          { text: 'AiDraw', link: 'https://guide.novelai.dev' },
          { text: 'MutsukiBot', link: 'https://nb.novelai.dev' },
        ],
      }],
    }],

    sidebar: [{
      text: '指南',
      items: [
        { text: '介绍', link: '/' },
        { text: '用法', link: '/usage' },
        { text: '配置项', link: '/config' },
        { text: '更多资源', link: '/more' },
      ],
    }, {
      text: 'FAQ',
      items: [
        { text: '插件相关', link: '/faq/network' },
        { text: '适配器相关', link: '/faq/adapter' },
      ],
    }, {
      text: '更多',
      items: [
        { text: 'Koishi 官网', link: 'https://koishi.chat' },
        { text: 'NovelAI.dev', link: 'https://novelai.dev' },
        { text: '支持作者', link: 'https://afdian.net/a/shigma' },
      ],
    }],

    socialLinks: {
      discord: 'https://discord.com/invite/xfxYwmd284',
      github: 'https://github.com/koishijs/novelai-bot',
    },

    footer: {
      message: `Released under the MIT License.`,
      copyright: 'Copyright © 2022-present Shigma & Ninzore',
    },

    editLink: {
      pattern: 'https://github.com/koishijs/novelai-bot/edit/master/docs/:path',
    },
  },
})


================================================
FILE: docs/.vitepress/theme/index.ts
================================================
import { defineTheme } from '@koishijs/vitepress/client'

export default defineTheme({})


================================================
FILE: docs/config.md
================================================
# 配置项

## 登录设置

### type

- 类型:`'login' | 'token' | 'naifu' | 'sd-webui' | 'stable-horde'`
- 默认值:`'token'`

登录方式。`login` 表示使用账号密码登录,`token` 表示使用授权令牌登录。`naifu`、`sd-webui` 和 `stable-horde` 对应着其他类型的后端。

### email

- 类型:`string`
- 当 `type` 为 `login` 时必填

你的账号邮箱。

### password

- 类型:`string`
- 当 `type` 为 `login` 时必填

你的账号密码。

### token

- 类型:`string`
- 当 `type` 为 `token` 时必填

授权令牌。获取方式如下:

1. 在网页中登录你的 NovelAI 账号
2. 打开控制台 (F12),并切换到控制台 (Console) 标签页
3. 输入下面的代码并按下回车运行

```js
console.log(JSON.parse(localStorage.session).auth_token)
```

4. 输出的字符串就是你的授权令牌

### endpoint

- 类型:`string`
- 默认值:`'https://api.novelai.net'`
- 当 `type` 为 `naifu` 或 `sd-webui` 时必填

API 服务器地址。如果你搭建了私服,可以将此项设置为你的服务器地址。

### headers

- 类型:`Dict<string>`
- 默认值:官服的 `Referer` 和 `User-Agent`

要附加的额外请求头。如果你的 `endpoint` 是第三方服务器,你可能需要设置正确的请求头,否则请求可能会被拒绝。

### trustedWorkers

- 类型: `boolean`
- 默认值: `false`
- 当 `type` 为 `stable-horde` 时可选

是否只请求可信任工作节点。

### pollInterval

- 类型: `number`
- 默认值: `1000`
- 当 `type` 为 `stable-horde` 时可选

轮询进度间隔时长。单位为毫秒。

## 参数设置

### model

- 类型:`'safe' | 'nai' | 'furry'`
- 默认值:`'nai'`

默认的生成模型。

### sampler

- 类型:`'k_euler_ancestral' | 'k_euler' | 'k_lms' | 'plms' | 'ddim'`
- 默认值:`'k_euler_ancestral'`

默认的采样器。

### scale

- 类型:`number`
- 默认值:`11`

默认对输入的服从度。

### textSteps

- 类型:`number`
- 默认值:`28`

文本生图时默认的迭代步数。

### imageSteps

- 类型:`number`
- 默认值:`50`

以图生图时默认的迭代步数。

### maxSteps

- 类型:`number`
- 默认值:`64`

允许的最大迭代步数。

### strength

- 类型:`number`
- 默认值:`0.7`
- 取值范围:`(0, 1]`

默认的重绘强度。

### resolution

- 类型:`'portrait' | 'square' | 'landscape' | { width: number, height: number }`
- 默认值:`'portrait'`

默认生成的图片尺寸。

### maxResolution

- 类型:`number`
- 默认值:`1024`

允许生成的宽高最大值。

## 输入设置

### basePrompt

- 类型: `string`
- 默认值: `'masterpiece, best quality'`

所有请求的附加标签。默认值相当于网页版的「Add Quality Tags」功能。

### negativePrompt

- 类型: `string`
- 默认值:
  ```text
  nsfw, lowres, bad anatomy, bad hands, text, error, missing fingers,
  extra digit, fewer digits, cropped, worst quality, low quality,
  normal quality, jpeg artifacts, signature, watermark, username, blurry
  ```

所有请求附加的负面标签。默认值相当于网页版的「Low Quality + Bad Anatomy」排除。

### forbidden

- 类型:`string`
- 默认值:`''`

违禁词列表。请求中的违禁词将会被自动删除。

违禁词语法与关键词类似,使用逗号隔开英文单词。由于它只用于过滤输入,因此不接受影响因子和要素混合等高级语法。默认情况下,每个违禁词均采用模糊匹配,即只要输入的某个关键词中包含任何一个违禁词作为子串,就会被自动删除。如果要使用精确匹配,可以在词尾加上 `!`。例如 `sex!` 仅会过滤 `sex toys` 而不过滤 `sexy girl`。

默认情况下本插件不设违禁词。对于想要禁用 nsfw 内容的用户,下面的违禁词表可供参考:

```text
guro, nipple, anal, anus, masturbation, sex!, rape, fuck,
dick, testis, nude, nake, cum, nudity, virgina, penis, nsfw,
topless, ass, bdsm, footjob, handjob, fellatio, deepthroat,
cum, ejaculation, bukkake, orgasm, pussy, bloody
```

### placement

- 类型:`'before' | 'after'`
- 默认值:`'after'`

默认附加标签相对用户输入的摆放位置。

设置为 `before` 意味着默认标签拥有更高的优先级 (如果希望与 NovelAI 官网行为保持一致,推荐这个选项),而设置为 `after` 将允许用户更高的创作自由度。

在 `before` 模式下,用户仍然可以通过 `-O, --override` 选项手动忽略默认标签;而在 `after` 模式下,用户仍然可以通过将基础标签写在前面的方式手动提高基础标签的优先级。

### translator

- 类型:`boolean`
- 默认值:`true`

是否启用自动翻译。安装任意 [翻译插件](https://translator.koishi.chat) 后即可自动将用户输入转换为英文。

### latinOnly

- 类型:`boolean`
- 默认值:`false`

是否只接受英文单词。启用后将对于非拉丁字母的输入进行错误提示。

::: tip
[自动翻译](#translator) 会转化用户输入,因此不建议与此选项同时启用。
:::

### maxWords

- 类型:`number`
- 默认值:`0`

用户输入的最大词数 (设置为 `0` 时无限制)。下面是一些细节设定:

- 这个配置项限制的是词数而不是标签数或字符数 (`girl, pleated skirt` 的词数为 3)
- 正向标签和反向标签分别计数,每种均不能超出此限制,但总词数可以达到限制的两倍
- 如果开启了 [自动翻译](#translator),此限制将作用于翻译后的结果
- 默认附加的正向和反向标签均不计入此限制

## 高级设置

### output

- 类型:`'minimal' | 'default' | 'verbose'`
- 默认值:`'default'`

输出方式。`minimal` 表示只发送图片,`default` 表示发送图片和关键信息,`verbose` 表示发送全部信息。

### allowAnlas

- 类型:`boolean | number`
- 默认值:`true`

是否启用高级功能。高级功能包括:

- 以图生图相关功能
- `-r, --resolution` 选项中,手动指定具体宽高
- `-O, --override` 选项
- `-t, --steps` 选项
- `-n, --noise` 选项
- `-N, --strength` 选项

当设置为数字时,表示使用上述功能所需的最低权限等级。

### requestTimeout

- 类型:`number`
- 默认值:`30000`

当请求超过这个时间时会中止并提示超时。

### maxRetryCount

- 类型:`number`
- 默认值:`3`

连接失败时最大的重试次数。

<!-- ### recallTimeout

- 类型:`number`
- 默认值:`0`

图片发送后自动撤回的时间 (设置为 `0` 禁用此功能)。 -->

### maxIterations

- 类型:`number`
- 默认值:`1`

允许的最大绘制次数。参见 [批量生成](./usage.md#批量生成)。

### maxConcurrency

- 类型:`number`
- 默认值:`0`

单个频道下的最大并发数量 (设置为 `0` 以禁用此功能)。


================================================
FILE: docs/faq/adapter.md
================================================
# 适配器相关

## OneBot

### 消息发送失败,账号可能被风控

如果你刚开始使用 go-cqhttp,建议挂机 3-7 天,即可解除风控。

### 未连接到 go-cqhttp 子进程

请尝试重新下载 gocqhttp 插件。

### 选择登陆方式的按钮存在但点击无效

已知问题:<https://github.com/koishijs/koishi-plugin-gocqhttp/issues/7>

## Discord

### 连接失败:disallowed intent(s)

解决方案:<https://github.com/koishijs/koishi/issues/804>

## Telegram

### 群组消息不接收

解决方案:<https://github.com/koishijs/koishi/issues/513>


================================================
FILE: docs/faq/network.md
================================================
# 插件相关

## 功能相关

### 使用这个插件必须花钱吗?

如果你用默认配置,那么是需要的。你也可以选择自己搭建服务器或使用 colab 等,这些方案都是免费的。

## 网络相关

### 请求超时

如果偶尔发生无需在意。如果总是发生请尝试调高 `requestTimeout` 的数值或者配置 `proxyAgent`。

`proxyAgent` 设置的方式为

前往插件配置-全局设置-最下方的请求设置-使用的代理服务器地址-填入代理服务器地址-点击右上角的重载配置。

你要填入的内容为 `socks://ip:port` 例如 `socks://127.0.0.1:7890`。

### 未知错误 400 或 401

请确认 `type` 是否设置为 `token`。

将 `type` 切换成 `login` 后重载,再次切换成 `token` 重载可以解决此问题。

### 未知错误 404

请确认 `endpoint` 是否正确,以及版本是否是最新。

如果你选择的 `type` 是 `sd-webui`,你还需确认你使用的 `sd-webui` 是来自于 `AUTOMATIC1111` 的版本,**且在启动时添加了 `--api` 参数**。

如果部署 sd-webui 与运行 koishi 的不是同一台电脑,且未通过隧道转发本地端口, 那么你还需要添加 `--listen` 参数。

### 未知错误 422

请更新插件版本。如插件版本已是最新,请尝试重置插件配置,并重新填写相应字段解决。

### 未知错误 500

请更新插件版本。

### 未知错误 502

如果你选择的 `type` 是 `sd-webui`,且采用了公网转发的方式提供服务 (即在启动的时候传递了 `--share` 或 `--ngrok` 参数),请确认你运行 `sd-webui` 的机器的网络情况,及与上述隧道服务的服务器的连通情况。

### 未知错误 503

如果你使用 NovelAI 官网作为后端 (`login` 或 `token`),请尝试将 `endpoint` 中的 `api` 替换成 `backend-production-svc` 再试试。


================================================
FILE: docs/index.md
================================================
# 介绍

基于 [NovelAI](https://novelai.net/) 的画图插件。已实现功能:

- 绘制图片
- 更改模型、采样器、图片尺寸
- 高级请求语法
- 自定义违禁词表
- 中文关键词自动翻译
- 发送一段时间后自动撤回
- 连接到私服 · [SD-WebUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) · [Stable Horde](https://stablehorde.net/)
- img2img · 图片增强

得益于 Koishi 的插件化机制,只需配合其他插件即可实现更多功能:

- 多平台支持 (QQ、Discord、Telegram、开黑啦等)
- 速率限制 (限制每个用户每天可以调用的次数和每次调用的间隔)
- 上下文管理 (限制在哪些群聊中哪些用户可以访问)
- 多语言支持 (为使用不同语言的用户提供对应的回复)

**所以快去给 [Koishi](https://github.com/koishijs/koishi) 点个 star 吧!**

## 效果展示

以下图片均使用本插件在聊天平台生成:

| ![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) |
|:-:|:-:|:-:|:-:|
| ![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) |

## 快速搭建

::: warning
在此之前,你需要一个**拥有有效付费计划的 NovelAI 账号**,本插件只使用 NovelAI 提供的接口。
付费计划请自行前往 [NovelAI](https://novelai.net/) 了解。
:::

给没有使用过 Koishi 的新人提供一份简单的快速搭建指南:

1. 前往[这里](https://koishi.chat/manual/starter/windows.html)下载 Koishi 桌面版
2. 启动桌面版,你将会看到一个控制台界面
3. 点击左侧的「插件市场」,搜索「novelai」并点击「安装」
4. 点击左侧的「插件配置」,选择「novelai」插件,并在以下两种方案中**任选一种**:
    - 选择登录方式为「账号密码」,并在「email」和「password」中填入邮箱和密码 (推荐)
    - 选择登录方式为「授权令牌」,并在「token」中填入授权令牌 ([获取方式](./config.md#token))
5. 点击右上角的「启用」按钮
6. 现在你已经可以在「沙盒」中使用画图功能了!

如果想进一步在 QQ 中使用,可继续进行下列操作:

1. 准备一个用于搭建机器人的 QQ 号 (等级不要过低,否则可能被风控)
2. 点击左侧的「插件配置」,选择「onebot」插件,完成以下配置:
    - 在「selfId」填写你的 QQ 号
    - 开启「gocqhttp.enable」选项
3. 点击右上角的「启用」按钮
4. 使用你填写的 QQ 号进行扫码登录
5. 现在你可以在 QQ 上中使用画图功能了!


================================================
FILE: docs/more.md
================================================
# 更多资源

## 公开机器人

[这里](https://github.com/koishijs/novelai-bot/discussions/75) 收集了基于 NovelAI Bot 搭建的公开机器人。大家可以自行前往这些机器人所在的群组进行游玩和体验。

### QQ 群

- 群号:767973430

### Kook (开黑啦)

- https://kook.top/9MxXht

### Discord

- https://discord.gg/w9uDYbBHnx

### Telegram

- https://t.me/+hhcuca5BUIs5YWM1


## 优质作品

[这里](https://github.com/koishijs/novelai-bot/discussions/88) 收集了使用 NovelAI Bot 创作的优质作品。也欢迎大家将自己绘制的图片分享到这里,还有机会被收录到首页的轮播图中。

## 友情链接

[NovelAI.dev](https://novelai.dev) 是一个以技术宅为核心的 AI 绘画爱好者群体。我们围绕 AI 绘图技术开发了更多应用:

- [法术解析](https://spell.novelai.dev/):从 NovelAI 生成的图片读取内嵌的 prompt
- [标签超市](https://tags.novelai.dev/):快速构建 Danbooru 标签组合
- [绘世百科](https://wiki.novelai.dev/):收集和整理 AI 绘图相关资料



================================================
FILE: docs/public/manifest.json
================================================
{
  "name": "NovelAI Bot",
  "short_name": "NovelAI Bot",
  "description": "基于 NovelAI 的画图机器人",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#5546a3",
  "icons": [
    {
      "src": "https://koishi.chat/logo.png",
      "sizes": "384x384",
      "type": "image/png"
    }
  ]
}


================================================
FILE: docs/usage.md
================================================
# 用法

## 基本用法

### 从文本生成图片 (text2img)

输入「约稿」+ 关键词进行图片绘制。例如:

```text
约稿 koishi
```

### 从图片生成图片 (img2img)

输入「约稿」+ 图片 + 关键词 进行图片绘制。例如:

```text
约稿 [图片] koishi
```

### 图片增强 (enhance)

图片增强用于优化已经生成的图片。输入「增强」+ 图片 + 关键词 进行图片增强。例如:

```text
增强 [图片] koishi
```

### 引用图片回复

考虑到某些平台并不支持在一条消息中同时出现图片和文本,我们也允许通过引用回复的方式触发 img2img 和 enhance 功能。例如:

```text
> [图片]
> [引用回复] 约稿/增强
```

### 多次生成 (iterations)

::: tip
此功能需要通过配置项 [`maxIterations`](./config.md#maxiterations) 手动开启。
:::

如果想要以一组输入多次生成图片,可以使用 `-i, --iterations` 参数:

```text
约稿 -i 10 koishi
```

### 批量生成 (batch)

::: tip
此功能需要通过配置项 [`maxIterations`](./config.md#maxiterations) 手动开启。
:::

如果想要以一组输入批量生成图片,可以使用 `-b, --batch` 参数:

```text
约稿 -b 10 koishi
```

### 输出方式 (output)

此插件提供了三种不同的输出方式:`minimal` 表示只发送图片,`default` 表示发送图片和关键信息,`verbose` 表示发送全部信息。你可以使用 `-o, --output` 手动指定输出方式,或通过配置项修改默认的行为。

```text
约稿 -o minimal koishi
```

## 关键词 (prompt)

使用关键词描述你想要的图像。关键词需要为英文,多个关键词之间用逗号分隔。每一个关键词也可以由多个单词组成,单词之间可以用空格或下划线分隔。例如:

```text
约稿 long hair, from_above, 1girl
```

::: tip
novelai-bot 同时兼容 NovelAI 和大部分 stable diffusion webui 的语法。
:::

### 负面关键词

使用 `-u` 或 `negative prompt:` 以添加负面关键词,避免生成不需要的内容。例如:

```text
约稿 girl
negative prompt: loli
```

### 影响因子

使用半角方括号 `[]` 包裹关键词以减弱该关键词的权重,使用半角花括号 `{}` 包裹关键词以增强该关键词的权重。例如:

```text
约稿 [tears], {spread legs}
```

每一层括号会增强 / 减弱 1.05 倍的权重。也可以通过多次使用括号来进一步增强或减弱关键词的权重。例如:

```text
约稿 [[tears]], {{{smile}}}
```

::: tip
除了影响因子外,关键词的顺序也会对生成结果产生影响。越重要的词应该放到越前面。
:::

### 要素混合

使用 `|` 分隔多个关键词以混合多个要素。例如:

```text
约稿 cat | frog
```

你将得到一只缝合怪 (字面意义上)。

可以进一步在关键词后添加 `:x` 来指定单个关键词的权重,`x` 的取值范围是 `0.1~100`,默认为 1。例如:

```text
约稿 cat :2 | dog
```

这时会得到一个更像猫的猫狗。

### 基础关键词

NovelAI Bot 允许用户配置基础的正面和负面关键词。它们会在请求时被添加在结尾。

如果想要手动忽略这些基础关键词,可以使用 `-O, --override` 参数。

## 高级功能

### 更改生成模型 (model)

可以用 `-m` 或 `--model` 切换生成模型,可选值包括:

- `safe`:较安全的图片
- `nai`:自由度较高的图片 (默认)
- `furry`:福瑞控特攻 (beta)

```text
约稿 -m furry koishi
```

### 设置分辨率 (resolution)

::: warning
此选项在图片增强时不可用。
:::

可以用 `-r` 或 `--resolution` 更改图片方向,它包含三种预设:

- `portrait`:768×512 (默认)
- `square`:640×640
- `landscape`:512×768

```text
约稿 -r landscape koishi
```

除了上述三种预设外,你还可以指定图片的具体长宽:

```text
约稿 -r 1024x1024 koishi
```

::: tip
由于 Stable Diffusion 的限制,输出图片的长宽都必须是 64 的倍数。当你输入的图片长宽不满足此条件时,我们会自动修改为接近此宽高比的合理数值。
:::

### 切换采样器 (sampler)

可以用 `-s` 或 `--sampler` 设置采样器,可选值包括:

- `k_euler_ancestral` (默认)
- `k_euler`
- `k_lms`
- `plms`
- `ddim`

即使使用了相同的输入,不同的采样器也会输出不同的内容。目前一般推荐使用 `k_euler_ancestral`,因为其能够提供相对稳定的高质量图片生成 (欢迎在 issue 中讨论各种采样器的区别)。

### 随机种子 (seed)

AI 会使用种子来生成噪音然后进一步生成你需要的图片,每次随机生成时都会有一个唯一的种子。使用 `-x` 或 `--seed` 并传入相同的种子可以让 AI 尝试使用相同的路数来生成图片。

```text
约稿 -x 1234567890 koishi
```

::: tip
注意:在同一模型和后端实现中,保持所有参数一致的情况下,相同的种子会产生同样的图片。取决于其他参数,后端实现和模型,相同的种子不一定生成相同的图片,但一般会带来更多的相似之处。
:::

### 迭代步数 (steps)

更多的迭代步数**可能**会有更好的生成效果,但是一定会导致生成时间变长。太多的steps也可能适得其反,几乎不会有提高。

一种做法是先使用较少的步数来进行快速生成来检查构图,直到找到喜欢的,然后再使用更多步数来生成最终图像。

默认情况下的迭代步数为 28 (传入图片时为 50),28 也是不会收费的最高步数。可以使用 `-t` 或 `--steps` 手动控制迭代步数。

```text
约稿 -t 50 koishi
```

### 对输入的服从度 (scale)

服从度较低时 AI 有较大的自由发挥空间,服从度较高时 AI 则更倾向于遵守你的输入。但如果太高的话可能会产生反效果 (比如让画面变得难看)。更高的值也需要更多计算。

有时,越低的 scale 会让画面有更柔和,更有笔触感,反之会越高则会增加画面的细节和锐度。

| 服从度 | 行为 |
| :---: | --- |
| 2~8   | 会自由地创作,AI 有它自己的想法 |
| 9~13  | 会有轻微变动,大体上是对的 |
| 14~18 | 基本遵守输入,偶有变动 |
| 19+   | 非常专注于输入 |

默认情况下的服从度为 12 (传入图片时为 11)。可以使用 `-c` 或 `--scale` 手动控制服从度。

```text
约稿 -c 10 koishi
```

### 强度 (strength)

::: tip
注意:该参数仅能在 img2img 模式下使用。
:::

AI 会参考该参数调整图像构成。值越低越接近于原图,越高越接近训练集平均画风。使用 `-N` 或 `--strength` 手动控制强度。

| 使用方式 | 推荐范围 |
| :---: | --- |
| 捏人 | 0.3~0.7 |
| 草图细化   | 0.2 |
| 细节设计  | 0.2~0.5 |
| 装饰性图案设计 | 0.2~0.36 |
| 照片转背景 | 0.3~0.7 |
| 辅助归纳照片光影 | 0.2~0.4 |

以上取值范围来自微博画师**帕兹定律**的[这条微博](https://share.api.weibo.cn/share/341911942,4824092660994264.html)。

### 噪音 (noise)

::: tip
注意:该参数仅能在 NovelAI / NAIFU 的 img2img 模式下使用。
:::

噪音是让 AI 生成细节内容的关键。更多的噪音可以让生成的图片拥有更多细节,但是太高的值会让产生异形,伪影和杂点。

如果你有一张有大片色块的草图,可以调高噪音以产生细节内容,但噪音的取值不宜大于强度。当强度和噪音都为 0 时,生成的图片会和原图几乎没有差别。

使用 `-n` 或 `--noise` 手动控制噪音。


================================================
FILE: package.json
================================================
{
  "name": "koishi-plugin-novelai",
  "description": "Generate images by diffusion models",
  "version": "1.27.0",
  "main": "lib/index.js",
  "typings": "lib/index.d.ts",
  "files": [
    "lib",
    "dist",
    "data"
  ],
  "browser": {
    "image-size": false,
    "libsodium-wrappers": false
  },
  "author": "Ninzore <cillerpro@gmail.com>",
  "contributors": [
    "Shigma <shigma10826@gmail.com>",
    {
      "name": "Maiko Tan",
      "email": "maiko.tan.coding@gmail.com"
    },
    "Ninzore <cillerpro@gmail.com>"
  ],
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/koishijs/koishi-plugin-novelai.git"
  },
  "bugs": {
    "url": "https://github.com/koishijs/koishi-plugin-novelai/issues"
  },
  "homepage": "https://bot.novelai.dev",
  "scripts": {
    "build": "atsc -b",
    "docs:dev": "vitepress dev docs --open",
    "docs:build": "vitepress build docs",
    "docs:serve": "vitepress serve docs"
  },
  "koishi": {
    "browser": true,
    "service": {
      "optional": [
        "translator"
      ]
    },
    "description": {
      "en": "Image Generation. Support [NovelAI](https://novelai.net/), Stable Diffusion and more.",
      "zh": "画图插件,支持 [NovelAI](https://novelai.net/)、Stable Diffusion 等",
      "zh-TW": "畫圖插件,支持 [NovelAI](https://novelai.net/)、Stable Diffusion 等",
      "fr": "Génération des images. Fonctionner sous [NovelAI](https://novelai.net/), Stable Diffusion et plus",
      "ja": "画像生成。[NovelAI](https://novelai.net/) や Stable Diffusion などに対応する。"
    }
  },
  "keywords": [
    "chatbot",
    "koishi",
    "plugin",
    "novelai",
    "ai",
    "paint",
    "image",
    "generate"
  ],
  "peerDependencies": {
    "koishi": "^4.18.7"
  },
  "devDependencies": {
    "@cordisjs/vitepress": "^3.3.2",
    "@koishijs/plugin-help": "^2.4.5",
    "@koishijs/translator": "^1.1.1",
    "@types/adm-zip": "^0.5.7",
    "@types/libsodium-wrappers-sumo": "^0.7.8",
    "@types/node": "^22.13.8",
    "atsc": "^1.2.2",
    "koishi": "^4.18.7",
    "sass": "^1.85.1",
    "typescript": "^5.8.2",
    "vitepress": "1.0.0-rc.40"
  },
  "dependencies": {
    "adm-zip": "^0.5.16",
    "image-size": "^1.2.0",
    "libsodium-wrappers-sumo": "^0.7.15"
  }
}


================================================
FILE: readme.md
================================================
# [koishi-plugin-novelai](https://bot.novelai.dev)

[![downloads](https://img.shields.io/npm/dm/koishi-plugin-novelai?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-novelai)
[![npm](https://img.shields.io/npm/v/koishi-plugin-novelai?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-novelai)

基于 [NovelAI](https://novelai.net/) 的画图插件。已实现功能:

- 绘制图片
- 更改模型、采样器、图片尺寸
- 高级请求语法
- 自定义违禁词表
- 中文关键词自动翻译
- 发送一段时间后自动撤回
- 连接到私服 · [SD-WebUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) · [Stable Horde](https://stablehorde.net/)
- img2img · 图片增强

得益于 Koishi 的插件化机制,只需配合其他插件即可实现更多功能:

- 多平台支持 (QQ、Discord、Telegram、开黑啦等)
- 速率限制 (限制每个用户每天可以调用的次数和每次调用的间隔)
- 上下文管理 (限制在哪些群聊中哪些用户可以访问)
- 多语言支持 (为使用不同语言的用户提供对应的回复)

**所以所以快去给 [Koishi](https://github.com/koishijs/koishi) 点个 star 吧!**

## 效果展示

以下图片均使用本插件在聊天平台生成:

| ![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) |
|:-:|:-:|:-:|:-:|
| ![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) |

## 使用教程

搭建教程、使用方法、参数配置、常见问题请见:<https://bot.novelai.dev>

## License

使用 [MIT](./LICENSE) 许可证发布。

```
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

[![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)


================================================
FILE: src/config.ts
================================================
import { Computed, Dict, Schema, Session, Time } from 'koishi'
import { Size } from './utils'

const options: Computed.Options = {
  userFields: ['authority'],
}

export const modelMap = {
  safe: 'safe-diffusion',
  nai: 'nai-diffusion',
  furry: 'nai-diffusion-furry',
  'nai-v3': 'nai-diffusion-3',
  'nai-v4-curated-preview': 'nai-diffusion-4-curated-preview',
  'nai-v4-full': 'nai-diffusion-4-full',
} as const

export const orientMap = {
  landscape: { height: 832, width: 1216 },
  portrait: { height: 1216, width: 832 },
  square: { height: 1024, width: 1024 },
} as const

export const hordeModels = require('../data/horde-models.json') as string[]

const ucPreset = [
  // Replace with the prompt words that come with novelai
  'nsfw, lowres, {bad}, error, fewer, extra, missing, worst quality',
  'jpeg artifacts, bad quality, watermark, unfinished, displeasing',
  'chromatic aberration, signature, extra digits, artistic error, username, scan, [abstract]',
].join(', ')

type Model = keyof typeof modelMap
type Orient = keyof typeof orientMap

export const models = Object.keys(modelMap) as Model[]
export const orients = Object.keys(orientMap) as Orient[]

export namespace scheduler {
  export const nai = ['native', 'karras', 'exponential', 'polyexponential'] as const
  export const nai4 = ['karras', 'exponential', 'polyexponential'] as const
  export const sd = ['Automatic', 'Uniform', 'Karras', 'Exponential', 'Polyexponential', 'SGM Uniform'] as const
  export const horde = ['karras'] as const
  export const comfyUI = ['normal', 'karras', 'exponential', 'sgm_uniform', 'simple', 'ddim_uniform'] as const
}

export namespace sampler {
  export const nai = {
    'k_euler_a': 'Euler ancestral',
    'k_euler': 'Euler',
    'k_lms': 'LMS',
    'ddim': 'DDIM',
    'plms': 'PLMS',
  }

  export const nai3 = {
    'k_euler': 'Euler',
    'k_euler_a': 'Euler ancestral',
    'k_dpmpp_2s_ancestral': 'DPM++ 2S ancestral',
    'k_dpmpp_2m': 'DPM++ 2M',
    'k_dpmpp_sde': 'DPM++ SDE',
    'ddim_v3': 'DDIM V3',
  }

  export const nai4 = {
    // recommended
    'k_euler': 'Euler',
    'k_euler_a': 'Euler ancestral',
    'k_dpmpp_2s_ancestral': 'DPM++ 2S ancestral',
    'k_dpmpp_2m_sde': 'DPM++ 2M SDE',
    // other
    'k_dpmpp_2m': 'DPM++ 2M',
    'k_dpmpp_sde': 'DPM++ SDE',
  }

  // samplers in stable-diffusion-webui
  // auto-generated by `build/fetch-sd-samplers.js`
  export const sd = require('../data/sd-samplers.json') as Dict<string>

  export const horde = {
    k_lms: 'LMS',
    k_heun: 'Heun',
    k_euler: 'Euler',
    k_euler_a: 'Euler a',
    k_dpm_2: 'DPM2',
    k_dpm_2_a: 'DPM2 a',
    k_dpm_fast: 'DPM fast',
    k_dpm_adaptive: 'DPM adaptive',
    k_dpmpp_2m: 'DPM++ 2M',
    k_dpmpp_2s_a: 'DPM++ 2S a',
    k_dpmpp_sde: 'DPM++ SDE',
    dpmsolver: 'DPM solver',
    lcm: 'LCM',
    DDIM: 'DDIM',
  }

  export const comfyui = {
    euler: 'Euler',
    euler_ancestral: 'Euler ancestral',
    heun: 'Heun',
    heunpp2: 'Heun++ 2',
    dpm_2: 'DPM 2',
    dpm_2_ancestral: 'DPM 2 ancestral',
    lms: 'LMS',
    dpm_fast: 'DPM fast',
    dpm_adaptive: 'DPM adaptive',
    dpmpp_2s_ancestral: 'DPM++ 2S ancestral',
    dpmpp_sde: 'DPM++ SDE',
    dpmpp_sde_gpu: 'DPM++ SDE GPU',
    dpmpp_2m: 'DPM++ 2M',
    dpmpp_2m_sde: 'DPM++ 2M SDE',
    dpmpp_2m_sde_gpu: 'DPM++ 2M SDE GPU',
    dpmpp_3m_sde: 'DPM++ 3M SDE',
    dpmpp_3m_sde_gpu: 'DPM++ 3M SDE GPU',
    ddpm: 'DDPM',
    lcm: 'LCM',
    ddim: 'DDIM',
    uni_pc: 'UniPC',
    uni_pc_bh2: 'UniPC BH2',
  }

  export function createSchema(map: Dict<string>) {
    return Schema.union(Object.entries(map).map(([key, value]) => {
      return Schema.const(key).description(value)
    })).loose().description('默认的采样器。').default('k_euler')
  }

  export function sd2nai(sampler: string, model: string): string {
    if (sampler === 'k_euler_a') return 'k_euler_ancestral'
    if (model === 'nai-v3' && sampler in nai3) return sampler
    else if (sampler in nai) return sampler
    return 'k_euler_ancestral'
  }
}

export const upscalers = [
  // built-in upscalers
  'None',
  'Lanczos',
  'Nearest',
  // third-party upscalers (might not be available)
  'LDSR',
  'ESRGAN_4x',
  'R-ESRGAN General 4xV3',
  'R-ESRGAN General WDN 4xV3',
  'R-ESRGAN AnimeVideo',
  'R-ESRGAN 4x+',
  'R-ESRGAN 4x+ Anime6B',
  'R-ESRGAN 2x+',
  'ScuNET GAN',
  'ScuNET PSNR',
  'SwinIR 4x',
] as const

export const latentUpscalers = [
  'Latent',
  'Latent (antialiased)',
  'Latent (bicubic)',
  'Latent (bicubic antialiased)',
  'Latent (nearest)',
  'Latent (nearest-exact)',
]

export interface Options {
  enhance: boolean
  model: string
  resolution: Size
  sampler: string
  seed: string
  steps: number
  scale: number
  noise: number
  strength: number
}

export interface PromptConfig {
  basePrompt?: Computed<string>
  negativePrompt?: Computed<string>
  forbidden?: Computed<string>
  defaultPromptSw?: boolean
  defaultPrompt?: Computed<string>
  placement?: Computed<'before' | 'after'>
  latinOnly?: Computed<boolean>
  translator?: boolean
  lowerCase?: boolean
  maxWords?: Computed<number>
}

export const PromptConfig: Schema<PromptConfig> = Schema.object({
  basePrompt: Schema.computed(Schema.string().role('textarea'), options).description('默认附加的标签。').default('best quality, amazing quality, very aesthetic, absurdres'),
  negativePrompt: Schema.computed(Schema.string().role('textarea'), options).description('默认附加的反向标签。').default(ucPreset),
  forbidden: Schema.computed(Schema.string().role('textarea'), options).description('违禁词列表。请求中的违禁词将会被自动删除。').default(''),
  defaultPromptSw: Schema.boolean().description('是否启用默认标签。').default(false),
  defaultPrompt: Schema.string().role('textarea', options).description('默认标签,可以在用户无输入prompt时调用。可选在sd-webui中安装dynamic prompt插件,配合使用以达到随机标签效果。').default(''),
  placement: Schema.computed(Schema.union([
    Schema.const('before').description('置于最前'),
    Schema.const('after').description('置于最后'),
  ]), options).description('默认附加标签的位置。').default('after'),
  translator: Schema.boolean().description('是否启用自动翻译。').default(true),
  latinOnly: Schema.computed(Schema.boolean(), options).description('是否只接受英文输入。').default(false),
  lowerCase: Schema.boolean().description('是否将输入的标签转换为小写。').default(true),
  maxWords: Schema.computed(Schema.natural(), options).description('允许的最大单词数量。').default(0),
}).description('输入设置')

interface FeatureConfig {
  anlas?: Computed<boolean>
  text?: Computed<boolean>
  image?: Computed<boolean>
  upscale?: Computed<boolean>
}

const naiFeatures = Schema.object({
  anlas: Schema.computed(Schema.boolean(), options).default(true).description('是否允许使用点数。'),
})

const sdFeatures = Schema.object({
  upscale: Schema.computed(Schema.boolean(), options).default(true).description('是否启用图片放大。'),
})

const features = Schema.object({
  text: Schema.computed(Schema.boolean(), options).default(true).description('是否启用文本转图片。'),
  image: Schema.computed(Schema.boolean(), options).default(true).description('是否启用图片转图片。'),
})

interface ParamConfig {
  model?: Model
  sampler?: string
  smea?: boolean
  smeaDyn?: boolean
  scheduler?: string
  rescale?: Computed<number>
  decrisper?: boolean
  upscaler?: string
  restoreFaces?: boolean
  hiresFix?: boolean
  hiresFixUpscaler: string
  scale?: Computed<number>
  textSteps?: Computed<number>
  imageSteps?: Computed<number>
  maxSteps?: Computed<number>
  strength?: Computed<number>
  noise?: Computed<number>
  resolution?: Computed<Orient | Size>
  maxResolution?: Computed<number>
}

export interface Config extends PromptConfig, ParamConfig {
  type: 'token' | 'login' | 'naifu' | 'sd-webui' | 'stable-horde' | 'comfyui'
  token?: string
  email?: string
  password?: string
  authLv?: Computed<number>
  authLvDefault?: Computed<number>
  output?: Computed<'minimal' | 'default' | 'verbose'>
  features?: FeatureConfig
  apiEndpoint?: string
  endpoint?: string
  headers?: Dict<string>
  nsfw?: Computed<'disallow' | 'censor' | 'allow'>
  maxIterations?: number
  maxRetryCount?: number
  requestTimeout?: number
  recallTimeout?: number
  maxConcurrency?: number
  pollInterval?: number
  trustedWorkers?: boolean
  workflowText2Image?: string
  workflowImage2Image?: string
}

const NAI4ParamConfig = Schema.object({
  sampler: sampler.createSchema(sampler.nai4).default('k_euler_a'),
  scheduler: Schema.union(scheduler.nai4).description('默认的调度器。').default('karras'),
  rescale: Schema.computed(Schema.number(), options).min(0).max(1).description('输入服从度调整规模。').default(0),
})

export const Config = Schema.intersect([
  Schema.object({
    type: Schema.union([
      Schema.const('token').description('授权令牌'),
      ...process.env.KOISHI_ENV === 'browser' ? [] : [Schema.const('login').description('账号密码')],
      Schema.const('naifu').description('naifu'),
      Schema.const('sd-webui').description('sd-webui'),
      Schema.const('stable-horde').description('Stable Horde'),
      Schema.const('comfyui').description('ComfyUI'),
    ]).default('token').description('登录方式。'),
  }).description('登录设置'),

  Schema.union([
    Schema.intersect([
      Schema.union([
        Schema.object({
          type: Schema.const('token'),
          token: Schema.string().description('授权令牌。').role('secret').required(),
        }),
        Schema.object({
          type: Schema.const('login'),
          email: Schema.string().description('账号邮箱。').required(),
          password: Schema.string().description('账号密码。').role('secret').required(),
        }),
      ]),
      Schema.object({
        apiEndpoint: Schema.string().description('API 服务器地址。').default('https://api.novelai.net'),
        endpoint: Schema.string().description('图片生成服务器地址。').default('https://image.novelai.net'),
        headers: Schema.dict(String).role('table').description('要附加的额外请求头。').default({
          'referer': 'https://novelai.net/',
          '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',
        }),
      }),
    ]),
    Schema.object({
      type: Schema.const('naifu'),
      token: Schema.string().description('授权令牌。').role('secret'),
      endpoint: Schema.string().description('API 服务器地址。').required(),
      headers: Schema.dict(String).role('table').description('要附加的额外请求头。'),
    }),
    Schema.object({
      type: Schema.const('sd-webui'),
      endpoint: Schema.string().description('API 服务器地址。').required(),
      headers: Schema.dict(String).role('table').description('要附加的额外请求头。'),
    }),
    Schema.object({
      type: Schema.const('stable-horde'),
      endpoint: Schema.string().description('API 服务器地址。').default('https://stablehorde.net/'),
      token: Schema.string().description('授权令牌 (API Key)。').role('secret').default('0000000000'),
      nsfw: Schema.union([
        Schema.const('disallow').description('禁止'),
        Schema.const('censor').description('屏蔽'),
        Schema.const('allow').description('允许'),
      ]).description('是否允许 NSFW 内容。').default('allow'),
      trustedWorkers: Schema.boolean().description('是否只请求可信任工作节点。').default(false),
      pollInterval: Schema.number().role('time').description('轮询进度间隔时长。').default(Time.second),
    }),
    Schema.object({
      type: Schema.const('comfyui'),
      endpoint: Schema.string().description('API 服务器地址。').required(),
      headers: Schema.dict(String).role('table').description('要附加的额外请求头。'),
      pollInterval: Schema.number().role('time').description('轮询进度间隔时长。').default(Time.second),
    }),
  ]),

  Schema.object({
    authLv: Schema.computed(Schema.natural(), options).description('使用画图全部功能所需要的权限等级。').default(0),
    authLvDefault: Schema.computed(Schema.natural(), options).description('使用默认参数生成所需要的权限等级。').default(0),
  }).description('权限设置'),

  Schema.object({
    features: Schema.object({}),
  }).description('功能设置'),

  Schema.union([
    Schema.object({
      type: Schema.union(['token', 'login']).hidden(),
      features: Schema.intersect([naiFeatures, features]),
    }),
    Schema.object({
      type: Schema.const('sd-webui'),
      features: Schema.intersect([features, sdFeatures]),
    }),
    Schema.object({
      features: Schema.intersect([features]),
    }),
  ]),

  Schema.object({}).description('参数设置'),

  Schema.union([
    Schema.object({
      type: Schema.const('sd-webui').required(),
      sampler: sampler.createSchema(sampler.sd),
      upscaler: Schema.union(upscalers).description('默认的放大算法。').default('Lanczos'),
      restoreFaces: Schema.boolean().description('是否启用人脸修复。').default(false),
      hiresFix: Schema.boolean().description('是否启用高分辨率修复。').default(false),
      hiresFixUpscaler: Schema.union(latentUpscalers.concat(upscalers)).description('高分辨率修复的放大算法。').default('Latent'),
      scheduler: Schema.union(scheduler.sd).description('默认的调度器。').default('Automatic'),
    }),
    Schema.object({
      type: Schema.const('stable-horde').required(),
      sampler: sampler.createSchema(sampler.horde),
      model: Schema.union(hordeModels).loose().description('默认的生成模型。'),
      scheduler: Schema.union(scheduler.horde).description('默认的调度器。').default('karras'),
    }),
    Schema.object({
      type: Schema.const('naifu').required(),
      sampler: sampler.createSchema(sampler.nai),
    }),
    Schema.object({
      type: Schema.const('comfyui').required(),
      sampler: sampler.createSchema(sampler.comfyui).description('默认的采样器。').required(),
      model: Schema.string().description('默认的生成模型的文件名。').required(),
      workflowText2Image: Schema.path({
        filters: [{ name: '', extensions: ['.json'] }],
        allowCreate: true,
      }).description('API 格式的文本到图像工作流。'),
      workflowImage2Image: Schema.path({
        filters: [{ name: '', extensions: ['.json'] }],
        allowCreate: true,
      }).description('API 格式的图像到图像工作流。'),
      scheduler: Schema.union(scheduler.comfyUI).description('默认的调度器。').default('normal'),
    }),
    Schema.intersect([
      Schema.object({
        model: Schema.union(models).loose().description('默认的生成模型。').default('nai-v3'),
      }),
      Schema.union([
        Schema.object({
          model: Schema.const('nai-v3'),
          sampler: sampler.createSchema(sampler.nai3),
          smea: Schema.boolean().description('默认启用 SMEA。'),
          smeaDyn: Schema.boolean().description('默认启用 SMEA 采样器的 DYN 变体。'),
          scheduler: Schema.union(scheduler.nai).description('默认的调度器。').default('native'),
        }),
        Schema.object({
          model: Schema.const('nai-v4-curated-preview'),
          ...NAI4ParamConfig.dict,
        }),
        Schema.object({
          model: Schema.const('nai-v4-full'),
          ...NAI4ParamConfig.dict,
        }),
        Schema.object({ sampler: sampler.createSchema(sampler.nai) }),
      ]),
      Schema.object({ decrisper: Schema.boolean().description('默认启用 decrisper') }),
    ]),
  ] as const),

  Schema.object({
    scale: Schema.computed(Schema.number(), options).description('默认对输入的服从度。').default(5),
    textSteps: Schema.computed(Schema.natural(), options).description('文本生图时默认的迭代步数。').default(28),
    imageSteps: Schema.computed(Schema.natural(), options).description('以图生图时默认的迭代步数。').default(50),
    maxSteps: Schema.computed(Schema.natural(), options).description('允许的最大迭代步数。').default(64),
    strength: Schema.computed(Schema.number(), options).min(0).max(1).description('默认的重绘强度。').default(0.7),
    noise: Schema.computed(Schema.number(), options).min(0).max(1).description('默认的重绘添加噪声强度。').default(0.2),
    resolution: Schema.computed(Schema.union([
      Schema.const('portrait').description('肖像 (832x2326)'),
      Schema.const('landscape').description('风景 (1216x832)'),
      Schema.const('square').description('方形 (1024x1024)'),
      Schema.object({
        width: Schema.natural().description('图片宽度。').default(1024),
        height: Schema.natural().description('图片高度。').default(1024),
      }).description('自定义'),
    ]), options).description('默认生成的图片尺寸。').default('portrait'),
    maxResolution: Schema.computed(Schema.natural(), options).description('允许生成的宽高最大值。').default(1920),
  }),

  PromptConfig,

  Schema.object({
    output: Schema.union([
      Schema.const('minimal').description('只发送图片'),
      Schema.const('default').description('发送图片和关键信息'),
      Schema.const('verbose').description('发送全部信息'),
    ]).description('输出方式。').default('default'),
    maxIterations: Schema.natural().description('允许的最大绘制次数。').default(1),
    maxRetryCount: Schema.natural().description('连接失败时最大的重试次数。').default(3),
    requestTimeout: Schema.number().role('time').description('当请求超过这个时间时会中止并提示超时。').default(Time.minute),
    recallTimeout: Schema.number().role('time').description('图片发送后自动撤回的时间 (设置为 0 以禁用此功能)。').default(0),
    maxConcurrency: Schema.number().description('单个频道下的最大并发数量 (设置为 0 以禁用此功能)。').default(0),
  }).description('高级设置'),
]) as Schema<Config>

interface Forbidden {
  pattern: string
  strict: boolean
}

export function parseForbidden(input: string) {
  return input.trim()
    .toLowerCase()
    .replace(/,/g, ',')
    .replace(/!/g, '!')
    .split(/(?:,\s*|\s*\n\s*)/g)
    .filter(Boolean)
    .map<Forbidden>((pattern: string) => {
      const strict = pattern.endsWith('!')
      if (strict) pattern = pattern.slice(0, -1)
      pattern = pattern.replace(/[^a-z0-9\u00ff-\uffff:]+/g, ' ').trim()
      return { pattern, strict }
    })
}

const backslash = /@@__BACKSLASH__@@/g

export function parseInput(session: Session, input: string, config: Config, override: boolean): string[] {
  if (!input) {
    return [
      null,
      [session.resolve(config.basePrompt), session.resolve(config.defaultPrompt)].join(','),
      session.resolve(config.negativePrompt)
    ]
  }

  input = input
    .replace(/\\\\/g, backslash.source)
    .replace(/,/g, ',')
    .replace(/(/g, '(')
    .replace(/)/g, ')')
    .replace(/《/g, '<')
    .replace(/》/g, '>')

  if (config.type === 'sd-webui') {
    input = input
      .split('\\{').map(s => s.replace(/\{/g, '(')).join('\\{')
      .split('\\}').map(s => s.replace(/\}/g, ')')).join('\\}')
  } else {
    input = input
      .split('\\(').map(s => s.replace(/\(/g, '{')).join('\\(')
      .split('\\)').map(s => s.replace(/\)/g, '}')).join('\\)')
  }

  input = input
    .replace(backslash, '\\')
    .replace(/_/g, ' ')

  if (session.resolve(config.latinOnly) && /[^\s\w"'“”‘’.,:|\\()\[\]{}<>-]/.test(input)) {
    return ['.latin-only']
  }

  const negative = []
  const placement = session.resolve(config.placement)
  const appendToList = (words: string[], input = '') => {
    const tags = input.split(/,\s*/g)
    if (placement === 'before') tags.reverse()
    for (let tag of tags) {
      tag = tag.trim()
      if (config.lowerCase) tag = tag.toLowerCase()
      if (!tag || words.includes(tag)) continue
      if (placement === 'before') {
        words.unshift(tag)
      } else {
        words.push(tag)
      }
    }
  }

  // extract negative prompts
  const capture = input.match(/(,\s*|\s+)(-u\s+|--undesired\s+|negative prompts?:\s*)([\s\S]+)/m)
  if (capture?.[3]) {
    input = input.slice(0, capture.index).trim()
    appendToList(negative, capture[3])
  }

  // remove forbidden words
  const forbidden = parseForbidden(session.resolve(config.forbidden))
  const positive = input.split(/,\s*/g).filter((word) => {
    // eslint-disable-next-line no-control-regex
    word = word.toLowerCase().replace(/[\x00-\x7f]/g, s => s.replace(/[^0-9a-zA-Z]/, ' ')).replace(/\s+/, ' ').trim()
    if (!word) return false
    for (const { pattern, strict } of forbidden) {
      if (strict && word.split(/\W+/g).includes(pattern)) {
        return false
      } else if (!strict && word.includes(pattern)) {
        return false
      }
    }
    return true
  }).map((word) => {
    if (/^<.+>$/.test(word)) return word.replace(/ /g, '_')
    return word.toLowerCase()
  })

  if (Math.max(getWordCount(positive), getWordCount(negative)) > (session.resolve(config.maxWords) || Infinity)) {
    return ['.too-many-words']
  }

  if (!override) {
    appendToList(positive, session.resolve(config.basePrompt))
    appendToList(negative, session.resolve(config.negativePrompt))
    if (config.defaultPromptSw) appendToList(positive, session.resolve(config.defaultPrompt))
  }

  return [null, positive.join(', '), negative.join(', ')]
}

function getWordCount(words: string[]) {
  return words.join(' ').replace(/[^a-z0-9]+/g, ' ').trim().split(' ').length
}


================================================
FILE: src/index.ts
================================================
import { Computed, Context, Dict, h, omit, Quester, Session, SessionError, trimSlash } from 'koishi'
import { Config, modelMap, models, orientMap, parseInput, sampler, upscalers, scheduler } from './config'
import { ImageData, NovelAI, StableDiffusionWebUI } from './types'
import { closestMultiple, download, forceDataPrefix, getImageSize, login, NetworkError, project, resizeInput, Size } from './utils'
import { } from '@koishijs/translator'
import { } from '@koishijs/plugin-help'
import AdmZip from 'adm-zip'
import { resolve } from 'path'
import { readFile } from 'fs/promises'

export * from './config'

export const reactive = true
export const name = 'novelai'

function handleError({ logger }: Context, session: Session, err: Error) {
  if (Quester.Error.is(err)) {
    if (err.response?.status === 402) {
      return session.text('.unauthorized')
    } else if (err.response?.status) {
      return session.text('.response-error', [err.response.status])
    } else if (err.code === 'ETIMEDOUT') {
      return session.text('.request-timeout')
    } else if (err.code) {
      return session.text('.request-failed', [err.code])
    }
  }
  logger.error(err)
  return session.text('.unknown-error')
}

export const inject = {
  required: ['http'],
  optional: ['translator'],
}

export function apply(ctx: Context, config: Config) {
  ctx.i18n.define('zh-CN', require('./locales/zh-CN'))
  ctx.i18n.define('zh-TW', require('./locales/zh-TW'))
  ctx.i18n.define('en-US', require('./locales/en-US'))
  ctx.i18n.define('fr-FR', require('./locales/fr-FR'))
  ctx.i18n.define('ja-JP', require('./locales/ja-JP'))

  const tasks: Dict<Set<string>> = Object.create(null)
  const globalTasks = new Set<string>()

  let tokenTask: Promise<string> = null
  const getToken = () => tokenTask ||= login(ctx)
  ctx.accept(['token', 'type', 'email', 'password'], () => tokenTask = null)

  type HiddenCallback = (session: Session<'authority'>) => boolean

  const useFilter = (filter: Computed<boolean>): HiddenCallback => (session) => {
    return session.resolve(filter) ?? true
  }

  const useBackend = (...types: Config['type'][]): HiddenCallback => () => {
    return types.includes(config.type)
  }

  const thirdParty = () => !['login', 'token'].includes(config.type)

  const restricted: HiddenCallback = (session) => {
    return !thirdParty() && useFilter(config.features.anlas)(session)
  }

  const noImage: HiddenCallback = (session) => {
    return !useFilter(config.features.image)(session)
  }

  const some = (...args: HiddenCallback[]): HiddenCallback => (session) => {
    return args.some(callback => callback(session))
  }

  const step = (source: string, session: Session) => {
    const value = +source
    if (value * 0 === 0 && Math.floor(value) === value && value > 0 && value <= session.resolve(config.maxSteps || Infinity)) return value
    throw new Error()
  }

  const resolution = (source: string, session: Session<'authority'>): Size => {
    if (source in orientMap) return orientMap[source]
    const cap = source.match(/^(\d+)[x×](\d+)$/)
    if (!cap) throw new Error()
    const width = closestMultiple(+cap[1])
    const height = closestMultiple(+cap[2])
    if (Math.max(width, height) > session.resolve(config.maxResolution || Infinity)) {
      throw new SessionError('commands.novelai.messages.invalid-resolution')
    }
    return { width, height, custom: true }
  }

  const cmd = ctx.command('novelai <prompts:text>')
    .alias('nai')
    .alias('imagine')
    .userFields(['authority'])
    .shortcut('imagine', { i18n: true, fuzzy: true })
    .shortcut('enhance', { i18n: true, fuzzy: true, options: { enhance: true } })
    .option('enhance', '-e', { hidden: some(restricted, thirdParty, noImage) })
    .option('model', '-m <model>', { type: models, hidden: thirdParty })
    .option('resolution', '-r <resolution>', { type: resolution })
    .option('output', '-o', { type: ['minimal', 'default', 'verbose'] })
    .option('override', '-O', { hidden: restricted })
    .option('sampler', '-s <sampler>')
    .option('seed', '-x <seed:number>')
    .option('steps', '-t <step>', { type: step, hidden: restricted })
    .option('scale', '-c <scale:number>')
    .option('noise', '-n <noise:number>', { hidden: some(restricted, thirdParty) })
    .option('strength', '-N <strength:number>', { hidden: restricted })
    .option('hiresFix', '-H', { hidden: () => config.type !== 'sd-webui' })
    .option('hiresFixSteps', '<step>', { type: step, hidden: () => config.type !== 'sd-webui' })
    .option('smea', '-S', { hidden: () => config.model !== 'nai-v3' })
    .option('smeaDyn', '-d', { hidden: () => config.model !== 'nai-v3' })
    .option('scheduler', '-C <scheduler:string>', {
      hidden: () => config.type === 'naifu',
      type: ['token', 'login'].includes(config.type)
        ? scheduler.nai
        : config.type === 'sd-webui'
        ? scheduler.sd
        : config.type === 'stable-horde'
        ? scheduler.horde
        : [],
    })
    .option('decrisper', '-D', { hidden: thirdParty })
    .option('undesired', '-u <undesired>')
    .option('noTranslator', '-T', { hidden: () => !ctx.translator || !config.translator })
    .option('iterations', '-i <iterations:posint>', { fallback: 1, hidden: () => config.maxIterations <= 1 })
    .option('batch', '-b <batch:option>', { fallback: 1, hidden: () => config.maxIterations <= 1 })
    .action(async ({ session, options }, input) => {
      if (config.defaultPromptSw) {
        if (session.user.authority < session.resolve(config.authLvDefault)) {
          return session.text('internal.low-authority')
        }
        if (session.user.authority < session.resolve(config.authLv)) {
          input = ''
          options = options.resolution ? { resolution: options.resolution } : {}
        }
      } else if (
        !config.defaultPromptSw
        && session.user.authority < session.resolve(config.authLv)
      ) return session.text('internal.low-auth')

      const haveInput = !!input?.trim()
      if (!haveInput && !config.defaultPromptSw) return session.execute('help novelai')

      // Check if the user is allowed to use this command.
      // This code is originally written in the `resolution` function,
      // but currently `session.user` is not available in the type infering process.
      // See: https://github.com/koishijs/novelai-bot/issues/159
      if (options.resolution?.custom && restricted(session)) {
        return session.text('.custom-resolution-unsupported')
      }

      const { batch = 1, iterations = 1 } = options
      const total = batch * iterations
      if (total > config.maxIterations) {
        return session.text('.exceed-max-iteration', [config.maxIterations])
      }

      const allowText = useFilter(config.features.text)(session)
      const allowImage = useFilter(config.features.image)(session)

      let imgUrl: string, image: ImageData
      if (!restricted(session) && haveInput) {
        input = h('', h.transform(h.parse(input), {
          img(attrs) {
            if (!allowImage) throw new SessionError('commands.novelai.messages.invalid-content')
            if (imgUrl) throw new SessionError('commands.novelai.messages.too-many-images')
            imgUrl = attrs.src
            return ''
          },
        })).toString(true)

        if (options.enhance && !imgUrl) {
          return session.text('.expect-image')
        }

        if (!input.trim() && !config.basePrompt) {
          return session.text('.expect-prompt')
        }
      } else {
        input = haveInput ? h('', h.transform(h.parse(input), {
          image(attrs) {
            throw new SessionError('commands.novelai.messages.invalid-content')
          },
        })).toString(true) : input
        delete options.enhance
        delete options.steps
        delete options.noise
        delete options.strength
        delete options.override
      }

      if (!allowText && !imgUrl) {
        return session.text('.expect-image')
      }

      if (haveInput && config.translator && ctx.translator && !options.noTranslator) {
        try {
          input = await ctx.translator.translate({ input, target: 'en' })
        } catch (err) {
          ctx.logger.warn(err)
        }
      }

      const [errPath, prompt, uc] = parseInput(session, input, config, options.override)
      if (errPath) return session.text(errPath)

      let token: string
      try {
        token = await getToken()
      } catch (err) {
        if (err instanceof NetworkError) {
          return session.text(err.message, err.params)
        }
        ctx.logger.error(err)
        return session.text('.unknown-error')
      }

      const model = modelMap[options.model]
      const seed = options.seed || Math.floor(Math.random() * Math.pow(2, 32))

      const parameters: Dict = {
        seed,
        prompt,
        n_samples: options.batch,
        uc,
        // 0: low quality + bad anatomy
        // 1: low quality
        // 2: none
        ucPreset: 2,
        qualityToggle: false,
        scale: options.scale ?? session.resolve(config.scale),
        steps: options.steps ?? session.resolve(imgUrl ? config.imageSteps : config.textSteps),
      }

      if (imgUrl) {
        try {
          image = await download(ctx, imgUrl)
        } catch (err) {
          if (err instanceof NetworkError) {
            return session.text(err.message, err.params)
          }
          ctx.logger.error(err)
          return session.text('.download-error')
        }

        if (options.enhance) {
          const size = getImageSize(image.buffer)
          if (size.width + size.height !== 1280) {
            return session.text('.invalid-size')
          }
          Object.assign(parameters, {
            height: size.height * 1.5,
            width: size.width * 1.5,
            noise: options.noise ?? 0,
            strength: options.strength ?? 0.2,
          })
        } else {
          options.resolution ||= resizeInput(getImageSize(image.buffer))
          Object.assign(parameters, {
            height: options.resolution.height,
            width: options.resolution.width,
            noise: options.noise ?? session.resolve(config.noise),
            strength: options.strength ?? session.resolve(config.strength),
          })
        }
      } else {
        if (!options.resolution) {
          const resolution = session.resolve(config.resolution)
          options.resolution = typeof resolution === 'string' ? orientMap[resolution] : resolution
        }
        Object.assign(parameters, {
          height: options.resolution.height,
          width: options.resolution.width,
        })
      }

      if (options.hiresFix || config.hiresFix) {
        // set default denoising strength to `0.75` for `hires fix` feature
        // https://github.com/koishijs/novelai-bot/issues/158
        parameters.strength ??= session.resolve(config.strength)
      }

      const getRandomId = () => Math.random().toString(36).slice(2)
      const container = Array(iterations).fill(0).map(getRandomId)
      if (config.maxConcurrency) {
        const store = tasks[session.cid] ||= new Set()
        if (store.size >= config.maxConcurrency) {
          return session.text('.concurrent-jobs')
        } else {
          container.forEach((id) => store.add(id))
        }
      }

      session.send(globalTasks.size
        ? session.text('.pending', [globalTasks.size])
        : session.text('.waiting'))

      container.forEach((id) => globalTasks.add(id))
      const cleanUp = (id: string) => {
        tasks[session.cid]?.delete(id)
        globalTasks.delete(id)
      }

      const path = (() => {
        switch (config.type) {
          case 'sd-webui':
            return image ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'
          case 'stable-horde':
            return '/api/v2/generate/async'
          case 'naifu':
            return '/generate-stream'
          case 'comfyui':
            return '/prompt'
          default:
            return '/ai/generate-image'
        }
      })()

      const getPayload = async () => {
        switch (config.type) {
          case 'login':
          case 'token':
          case 'naifu': {
            parameters.params_version = 1
            parameters.sampler = sampler.sd2nai(options.sampler, model)
            parameters.image = image?.base64 // NovelAI / NAIFU accepts bare base64 encoded image
            if (config.type === 'naifu') return parameters
            // The latest interface changes uc to negative_prompt, so that needs to be changed here as well
            if (parameters.uc) {
              parameters.negative_prompt = parameters.uc
              delete parameters.uc
            }
            parameters.dynamic_thresholding = options.decrisper ?? config.decrisper
            const isNAI3 = model === 'nai-diffusion-3'
            const isNAI4 = model === 'nai-diffusion-4-curated-preview' || model === 'nai-diffusion-4-full'
            if (isNAI3 || isNAI4) {
              parameters.params_version = 3
              parameters.legacy = false
              parameters.legacy_v3_extend = false
              parameters.noise_schedule = options.scheduler ?? config.scheduler
              // Max scale for nai-v3 is 10, but not 20.
              // If the given value is greater than 10,
              // we can assume it is configured with an older version (max 20)
              if (parameters.scale > 10) {
                parameters.scale = parameters.scale / 2
              }
              if (isNAI3) {
                parameters.sm_dyn = options.smeaDyn ?? config.smeaDyn
                parameters.sm = (options.smea ?? config.smea) || parameters.sm_dyn
                if (['k_euler_ancestral', 'k_dpmpp_2s_ancestral'].includes(parameters.sampler)
                  && parameters.noise_schedule === 'karras') {
                  parameters.noise_schedule = 'native'
                }
                if (parameters.sampler === 'ddim_v3') {
                  parameters.sm = false
                  parameters.sm_dyn = false
                  delete parameters.noise_schedule
                }
              } else if (isNAI4) {
                parameters.add_original_image = true // unknown
                parameters.cfg_rescale = session.resolve(config.rescale)
                parameters.characterPrompts = [] satisfies NovelAI.V4CharacterPrompt[]
                parameters.controlnet_strength = 1 // unknown
                parameters.deliberate_euler_ancestral_bug = false // unknown
                parameters.prefer_brownian = true // unknown
                parameters.reference_image_multiple = [] // unknown
                parameters.reference_information_extracted_multiple = [] // unknown
                parameters.reference_strength_multiple = [] // unknown
                parameters.skip_cfg_above_sigma = null // unknown
                parameters.use_coords = false // unknown
                parameters.v4_prompt = {
                  caption: {
                    base_caption: prompt,
                    char_captions: [],
                  },
                  use_coords: parameters.use_coords,
                  use_order: true,
                } satisfies NovelAI.V4PromptPositive
                parameters.v4_negative_prompt = {
                  caption: {
                    base_caption: parameters.negative_prompt,
                    char_captions: [],
                  },
                } satisfies NovelAI.V4Prompt
              }
            }
            return { model, input: prompt, action: 'generate', parameters: omit(parameters, ['prompt']) }
          }
          case 'sd-webui': {
            return {
              sampler_index: sampler.sd[options.sampler],
              scheduler: options.scheduler,
              init_images: image && [image.dataUrl], // sd-webui accepts data URLs with base64 encoded image
              restore_faces: config.restoreFaces ?? false,
              enable_hr: options.hiresFix ?? config.hiresFix ?? false,
              hr_second_pass_steps: options.hiresFixSteps ?? 0,
              hr_upscaler: config.hiresFixUpscaler ?? 'None',
              ...project(parameters, {
                prompt: 'prompt',
                batch_size: 'n_samples',
                seed: 'seed',
                negative_prompt: 'uc',
                cfg_scale: 'scale',
                steps: 'steps',
                width: 'width',
                height: 'height',
                denoising_strength: 'strength',
              }),
            }
          }
          case 'stable-horde': {
            const nsfw = session.resolve(config.nsfw)
            return {
              prompt: parameters.prompt,
              params: {
                sampler_name: options.sampler,
                cfg_scale: parameters.scale,
                denoising_strength: parameters.strength,
                seed: parameters.seed.toString(),
                height: parameters.height,
                width: parameters.width,
                post_processing: [],
                karras: options.scheduler?.toLowerCase() === 'karras',
                hires_fix: options.hiresFix ?? config.hiresFix ?? false,
                steps: parameters.steps,
                n: parameters.n_samples,
              },
              nsfw: nsfw !== 'disallow',
              trusted_workers: config.trustedWorkers,
              censor_nsfw: nsfw === 'censor',
              models: [options.model],
              source_image: image?.base64,
              source_processing: image ? 'img2img' : undefined,
              // support r2 upload
              // https://github.com/koishijs/novelai-bot/issues/163
              r2: true,
            }
          }
          case 'comfyui': {
            const workflowText2Image = config.workflowText2Image
              ? resolve(ctx.baseDir, config.workflowText2Image)
              : resolve(__dirname, '../data/default-comfyui-t2i-wf.json')
            const workflowImage2Image = config.workflowImage2Image
              ? resolve(ctx.baseDir, config.workflowImage2Image)
              : resolve(__dirname, '../data/default-comfyui-i2i-wf.json')
            const workflow = image ? workflowImage2Image : workflowText2Image
            ctx.logger.debug('workflow:', workflow)
            const prompt = JSON.parse(await readFile(workflow, 'utf8'))

            // have to upload image to the comfyui server first
            if (image) {
              const body = new FormData()
              const capture = /^data:([\w/.+-]+);base64,(.*)$/.exec(image.dataUrl)
              const [, mime] = capture

              let name = Date.now().toString()
              const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : ''
              if (ext) name += `.${ext}`
              const imageFile = new Blob([image.buffer], { type: mime })
              body.append('image', imageFile, name)
              const res = await ctx.http(trimSlash(config.endpoint) + '/upload/image', {
                method: 'POST',
                headers: {
                  ...config.headers,
                },
                data: body,
              })
              if (res.status === 200) {
                const data = res.data
                let imagePath = data.name
                if (data.subfolder) imagePath = data.subfolder + '/' + imagePath

                for (const nodeId in prompt) {
                  if (prompt[nodeId].class_type === 'LoadImage') {
                    prompt[nodeId].inputs.image = imagePath
                    break
                  }
                }
              } else {
                throw new SessionError('commands.novelai.messages.unknown-error')
              }
            }

            // only change the first node in the workflow
            for (const nodeId in prompt) {
              if (prompt[nodeId].class_type === 'KSampler') {
                prompt[nodeId].inputs.seed = parameters.seed
                prompt[nodeId].inputs.steps = parameters.steps
                prompt[nodeId].inputs.cfg = parameters.scale
                prompt[nodeId].inputs.sampler_name = options.sampler
                prompt[nodeId].inputs.denoise = options.strength ?? session.resolve(config.strength)
                prompt[nodeId].inputs.scheduler = options.scheduler ?? config.scheduler
                const positiveNodeId = prompt[nodeId].inputs.positive[0]
                const negativeeNodeId = prompt[nodeId].inputs.negative[0]
                const latentImageNodeId = prompt[nodeId].inputs.latent_image[0]
                prompt[positiveNodeId].inputs.text = parameters.prompt
                prompt[negativeeNodeId].inputs.text = parameters.uc
                prompt[latentImageNodeId].inputs.width = parameters.width
                prompt[latentImageNodeId].inputs.height = parameters.height
                prompt[latentImageNodeId].inputs.batch_size = parameters.n_samples
                break
              }
            }
            for (const nodeId in prompt) {
              if (prompt[nodeId].class_type === 'CheckpointLoaderSimple') {
                prompt[nodeId].inputs.ckpt_name = options.model ?? config.model
                break
              }
            }
            ctx.logger.debug('prompt:', prompt)
            return { prompt }
          }
        }
      }

      const getHeaders = () => {
        switch (config.type) {
          case 'login':
          case 'token':
          case 'naifu':
            return { Authorization: `Bearer ${token}` }
          case 'stable-horde':
            return { apikey: token }
        }
      }

      let finalPrompt = prompt
      const iterate = async () => {
        const request = async () => {
          const res = await ctx.http(trimSlash(config.endpoint) + path, {
            method: 'POST',
            timeout: config.requestTimeout,
            // Since novelai's latest interface returns an application/x-zip-compressed, a responseType must be passed in
            responseType: config.type === 'naifu' ? 'text' : ['login', 'token'].includes(config.type) ? 'arraybuffer' : 'json',
            headers: {
              ...config.headers,
              ...getHeaders(),
            },
            data: await getPayload(),
          })

          if (config.type === 'sd-webui') {
            const data = res.data as StableDiffusionWebUI.Response
            if (data?.info?.prompt) {
              finalPrompt = data.info.prompt
            } else {
              try {
                finalPrompt = (JSON.parse(data.info)).prompt
              } catch (err) {
                ctx.logger.warn(err)
              }
            }
            return forceDataPrefix(data.images[0])
          }
          if (config.type === 'stable-horde') {
            const uuid = res.data.id

            const check = () => ctx.http.get(trimSlash(config.endpoint) + '/api/v2/generate/check/' + uuid).then((res) => res.done)
            const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
            while (await check() === false) {
              await sleep(config.pollInterval)
            }
            const result = await ctx.http.get(trimSlash(config.endpoint) + '/api/v2/generate/status/' + uuid)
            const imgUrl = result.generations[0].img
            if (!imgUrl.startsWith('http')) {
              // r2 upload
              // in case some client doesn't support r2 upload and follow the ye olde way.
              return forceDataPrefix(result.generations[0].img, 'image/webp')
            }
            const imgRes = await ctx.http(imgUrl, { responseType: 'arraybuffer' })
            const b64 = Buffer.from(imgRes.data).toString('base64')
            return forceDataPrefix(b64, imgRes.headers.get('content-type'))
          }
          if (config.type === 'comfyui') {
            // get filenames from history
            const promptId = res.data.prompt_id
            const check = () => ctx.http.get(trimSlash(config.endpoint) + '/history/' + promptId)
              .then((res) => res[promptId] && res[promptId].outputs)
            const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
            let outputs
            while (!(outputs = await check())) {
              await sleep(config.pollInterval)
            }
            // get images by filename
            const imagesOutput: { data: ArrayBuffer; mime: string }[] = []
            for (const nodeId in outputs) {
              const nodeOutput = outputs[nodeId]
              if ('images' in nodeOutput) {
                for (const image of nodeOutput['images']) {
                  const urlValues = new URLSearchParams({ filename: image['filename'], subfolder: image['subfolder'], type: image['type'] }).toString()
                  const imgRes = await ctx.http(trimSlash(config.endpoint) + '/view?' + urlValues)
                  imagesOutput.push({ data: imgRes.data, mime: imgRes.headers.get('content-type') })
                  break
                }
              }
            }
            // return first image
            return forceDataPrefix(Buffer.from(imagesOutput[0].data).toString('base64'), imagesOutput[0].mime)
          }
          // event: newImage
          // id: 1
          // data:
          //                                                                        ↓ nai-v3
          if (res.headers.get('content-type') === 'application/x-zip-compressed' || res.headers.get('content-disposition')?.includes('.zip')) {
            const buffer = Buffer.from(res.data, 'binary') // Ensure 'binary' encoding
            const zip = new AdmZip(buffer)

            // Gets all files in the ZIP file
            const zipEntries = zip.getEntries()
            const firstImageBuffer = zip.readFile(zipEntries[0])
            const b64 = firstImageBuffer.toString('base64')
            return forceDataPrefix(b64, 'image/png')
          }
          return forceDataPrefix(res.data?.trimEnd().slice(27))
        }

        let dataUrl: string, count = 0
        while (true) {
          try {
            dataUrl = await request()
            break
          } catch (err) {
            if (Quester.Error.is(err)) {
              if (err.code && err.code !== 'ETIMEDOUT' && ++count < config.maxRetryCount) {
                continue
              }
            }
            return await session.send(handleError(ctx, session, err))
          }
        }

        if (!dataUrl.trim()) return await session.send(session.text('.empty-response'))

        function getContent() {
          const output = session.resolve(options.output ?? config.output)
          if (output === 'minimal') return h.image(dataUrl)
          const attrs = {
            userId: session.userId,
            nickname: session.author?.nickname || session.username,
          }
          const result = h('figure')
          const lines = [`seed = ${parameters.seed}`]
          if (output === 'verbose') {
            if (!thirdParty()) {
              lines.push(`model = ${model}`)
            }
            lines.push(
              `sampler = ${options.sampler}`,
              `steps = ${parameters.steps}`,
              `scale = ${parameters.scale}`,
            )
            if (parameters.image) {
              lines.push(
                `strength = ${parameters.strength}`,
                `noise = ${parameters.noise}`,
              )
            }
          }
          result.children.push(h('message', attrs, lines.join('\n')))
          result.children.push(h('message', attrs, `prompt = ${h.escape(finalPrompt)}`))
          if (output === 'verbose') {
            result.children.push(h('message', attrs, `undesired = ${h.escape(uc)}`))
          }
          result.children.push(h('message', attrs, h.image(dataUrl)))
          return result
        }

        ctx.logger.debug(`${session.uid}: ${finalPrompt}`)
        const messageIds = await session.send(getContent())
        if (messageIds.length && config.recallTimeout) {
          ctx.setTimeout(() => {
            for (const id of messageIds) {
              session.bot.deleteMessage(session.channelId, id)
            }
          }, config.recallTimeout)
        }
      }

      while (container.length) {
        try {
          await iterate()
          cleanUp(container.pop())
          parameters.seed++
        } catch (err) {
          container.forEach(cleanUp)
          throw err
        }
      }
    })

  ctx.accept(['model', 'sampler'], (config) => {
    const getSamplers = () => {
      switch (config.type) {
        case 'sd-webui':
          return sampler.sd
        case 'stable-horde':
          return sampler.horde
        default:
          return { ...sampler.nai, ...sampler.nai3 }
      }
    }

    cmd._options.model.fallback = config.model
    cmd._options.sampler.fallback = config.sampler
    cmd._options.sampler.type = Object.keys(getSamplers())
  }, { immediate: true })

  const subcmd = ctx
    .intersect(useBackend('sd-webui'))
    .intersect(useFilter(config.features.upscale))
    .command('novelai.upscale')
    .shortcut('upscale', { i18n: true, fuzzy: true })
    .option('scale', '-s <scale:number>', { fallback: 2 })
    .option('resolution', '-r <resolution>', { type: resolution })
    .option('crop', '-C, --no-crop', { value: false, fallback: true })
    .option('upscaler', '-1 <upscaler>', { type: upscalers })
    .option('upscaler2', '-2 <upscaler2>', { type: upscalers })
    .option('visibility', '-v <visibility:number>')
    .option('upscaleFirst', '-f', { fallback: false })
    .action(async ({ session, options }, input) => {
      let imgUrl: string
      h.transform(input, {
        image(attrs) {
          imgUrl = attrs.url
          return ''
        },
      })

      if (!imgUrl) return session.text('.expect-image')
      let image: ImageData
      try {
        image = await download(ctx, imgUrl)
      } catch (err) {
        if (err instanceof NetworkError) {
          return session.text(err.message, err.params)
        }
        ctx.logger.error(err)
        return session.text('.download-error')
      }

      const payload: StableDiffusionWebUI.ExtraSingleImageRequest = {
        image: image.dataUrl,
        resize_mode: options.resolution ? 1 : 0,
        show_extras_results: true,
        upscaling_resize: options.scale,
        upscaling_resize_h: options.resolution?.height,
        upscaling_resize_w: options.resolution?.width,
        upscaling_crop: options.crop,
        upscaler_1: options.upscaler,
        upscaler_2: options.upscaler2 ?? 'None',
        extras_upscaler_2_visibility: options.visibility ?? 1,
        upscale_first: options.upscaleFirst,
      }

      try {
        const { data } = await ctx.http<StableDiffusionWebUI.ExtraSingleImageResponse>(trimSlash(config.endpoint) + '/sdapi/v1/extra-single-image', {
          method: 'POST',
          timeout: config.requestTimeout,
          headers: {
            ...config.headers,
          },
          data: payload,
        })
        return h.image(forceDataPrefix(data.image))
      } catch (e) {
        ctx.logger.warn(e)
        return session.text('.unknown-error')
      }
    })

  ctx.accept(['upscaler'], (config) => {
    subcmd._options.upscaler.fallback = config.upscaler
  }, { immediate: true })
}


================================================
FILE: src/locales/de-DE.yml
================================================
commands:
  novelai:
    description: AI 画图
    usage: |-
      输入用逗号隔开的英文标签,例如 Mr.Quin, dark sword, red eyes。
      查找标签可以使用 Danbooru。
      快来给仓库点个 star 吧:https://github.com/koishijs/novelai-bot
    options:
      enhance: 图片增强模式
      model: 设定生成模型
      resolution: 设定图片尺寸
      override: 禁用默认标签
      sampler: 设置采样器
      seed: 设置随机种子
      steps: 设置迭代步数
      scale: 设置对输入的服从度
      strength: 图片修改幅度
      noise: 图片噪声强度
      hiresFix: 启用高分辨率修复
      undesired: 排除标签
      noTranslator: 禁用自动翻译
      iterations: 设置绘制次数
    messages:
      exceed-max-iteration: 超过最大绘制次数。
      expect-prompt: 请输入标签。
      expect-image: 请输入图片。
      latin-only: 只接受英文输入。
      too-many-words: 输入的单词数量过多。
      forbidden-word: 输入含有违禁词。
      concurrent-jobs: |-
        <random>
        <>等会再约稿吧,我已经忙不过来了……</>
        <>是数位板没电了,才…才不是我不想画呢!</>
        <>那你得先教我画画(理直气壮</>
        </random>
      waiting: |-
        <random>
        <>少女绘画中……</>
        <>在画了在画了</>
        <>你就在此地不要走动,等我给你画一幅</>
        </random>
      pending: 在画了在画了,不过前面还有 {0} 个稿……
      invalid-size: 增强功能仅适用于被生成的图片。普通的 img2img 请直接使用「约稿」而不是「增强」。
      invalid-resolution: 非法的图片尺寸。宽高必须都为 64 的倍数。
      custom-resolution-unsupported: 不支持自定义图片尺寸。
      file-too-large: 文件体积过大。
      unsupported-file-type: 不支持的文件格式。
      download-error: 图片解析失败。
      unknown-error: 发生未知错误。
      response-error: 发生未知错误 ({0})。
      empty-response: 服务器返回了空白图片,请稍后重试。
      request-failed: 请求失败 ({0}),请稍后重试。
      request-timeout: 请求超时。
      invalid-password: 邮箱或密码错误。
      invalid-token: 令牌无效或已过期,请联系管理员。
      unauthorized: 令牌未授权,可能需要续费,请联系管理员。
  novelai.upscale:
    description: AI 放大图片
    options:
      scale: 设置放大倍数
      resolution: 设定放大尺寸
      crop: 是否裁剪以适应尺寸
      upscaler: 设置放大模型
      upscaler2: 设置放大模型 2
      upscaler2visibility: 设置放大模型 2 的可见度
      upscaleFirst: 先放大再执行面部修复
    messages:
      expect-image: 请输入图片。
      download-error: 图片解析失败。
      unknown-error: 发生未知错误。


================================================
FILE: src/locales/en-US.yml
================================================
commands:
  novelai:
    description: Generate Images from Novel AI
    usage: |-
      Enter "novelai" with English prompt or tags, e.g. a girl in the forest, blonde hair, red eyes, white dress, etc.
      You can also use comma separated tags like those on Danbooru.
      Star it: https://github.com/koishijs/novelai-bot
    options:
      enhance: Image Enhance Mode
      model: Set Model for Generation
      resolution: Set Image Resolution
      override: Disable Default Prompts
      sampler: Set Sampler
      seed: Set Random Seed
      steps: Set Iteration Steps
      scale: Set CFG Scale
      strength: Set Denoising Strength
      noise: Set Noising Strength
      hiresFix: Enable Hires Fix.
      undesired: Negative Prompt
      noTranslator: Disable Auto Translation
      iterations: Set Batch Count.
    messages:
      exceed-max-iteration: Exceeded max batch count.
      expect-prompt: Expect a prompt.
      expect-image: Expect an image.
      latin-only: Invalid prompt, only English words can be used.
      too-many-words: Too many words in prompt.
      forbidden-word: Forbidden words in prompt.
      concurrent-jobs: |-
        <random>
          <>Too busy to handle your request...</>
          <>Brb power nap :zzz:</>
          <>(*~*) Have no time to draw a new one.</>
        </random>
      waiting: |-
        <random>
          <>The illustrator starts painting.</>
          <>Monet and Da Vinci, whose style is better for this?</>
        </random>
      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>"
      invalid-size: The Enhance mode can only be used for images generated. Use "novelai" without enhance option if you are using normal img2img.
      invalid-resolution: Invalid resolution for image generation. The width and height of image should be multiple of 64.
      custom-resolution-unsupported: Custom resolution is not supported.
      file-too-large: File is too large.
      unsupported-file-type: Unsupported file type.
      download-error: Parsing image failed.
      unknown-error: An unknown error occurred.
      response-error: An unknown error occurred ({0}).
      empty-response: The server didn't return a valid image.
      request-failed: Request failed ({0}).
      request-timeout: Request timeout.
      invalid-password: Incorrect email address or password.
      invalid-token: The token is invalid or expired. Please contact your administrator.
      unauthorized: The token is unauthorized, this happens while your account didn't have a valid subscription. Please contact your administrator.
  novelai.upscale:
    description: Upscale Images by AI
    options:
      scale: Set Upscale By
      resolution: Set Upscale To
      crop: Crop Image Before Upscaling
      upscaler: Set Upscaler
      upscaler2: Set Upscaler
      upscaler2visibility: Set Visibility of Upscaler 2
      upscaleFirst: Upscale Image Before Restoring Face
    messages:
      expect-image: Expect an image.
      download-error: Parsing image failed.
      unknown-error: An unknown error occurred.


================================================
FILE: src/locales/fr-FR.yml
================================================
commands:
  novelai:
    description: Générer des images sur IA
    usage: |-
      Entrez « novelai » avec les descriptions textuelles (anglais : prompt) de la scène que vous souhaitez générer.
      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.
      Vous pouvez utiliser des balises séparées par des virgules, comme sur Danbooru.
      Donnez-lui une étoile : https://github.com/koishijs/novelai-bot
    options:
      enhance: Mode d'amélioration de l'image
      model: Définir le modèle pour la génération
      resolution: Définir la taille de l'image
      override: Remplacer les descriptions textuelles de base
      sampler: Définir l'échantillonneur
      seed: Définir la graine aléatoire
      steps: Définir les étapes de l'itération
      scale: Définir CFG Scale
      strength: Définir l'intensité du débruitage
      noise: Définir l'intensité du bruit
      hiresFix: Activer la correction pour la résolution haute.
      undesired: Définir les descriptions textuelles négatives
      noTranslator: Désactiver la traduction automatique
      iterations: Définir le nombre des générations
    messages:
      exceed-max-iteration: Trop du nombre des générations.
      expect-prompt: Attendrez-vous les descriptions textuelles valides.
      expect-image: Attendrez-vous une image.
      latin-only: Les descriptions textuelles ne sont pas valides, vous ne pouvez utiliser que des mots anglais.
      too-many-words: Trop de mots saisis.
      forbidden-word: Les descriptions textuelles contiennent des mots prohibés.
      concurrent-jobs: |-
        <random>
          <>Trop occupé pour répondre à votre demande...</>
          <>Courte sieste :zzz:</>
          <>(*~*) Pas le temps d'en dessiner un nouveau.</>
        </random>
      waiting: |-
        <random>
          <>D'accord. Je dessine de belles images pour vous.</>
          <>Votre demande est en cours de génération, veuillez attendre un moment.</>
          <>L'illustrateur commence à peindre.</>
          <>Monet et Da Vinci, quel style convient le mieux à cette image ?</>
        </random>
      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>"
      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 ».
      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.
      custom-resolution-unsupported: La personnalisation de la résolution n'est pas prise en charge.
      file-too-large: Le fichier est trop important.
      unsupported-file-type: Le format de fichier non reconnu.
      download-error: Une erreur d'analyse syntaxique de l'image s'est produite.
      unknown-error: Une erreur inconnue s'est produite.
      response-error: 'Une erreur inconnue s''est produite : ({0}).'
      empty-response: Le serveur répond avec l'image invalide.
      request-failed: 'La demande a échoué : ({0}).'
      request-timeout: Le délai d'attente de la demande dépassé.
      invalid-password: L'adresse électronique ou mot de passe introduit est incorrect.
      invalid-token: Le token est invalide ou a expiré. Veuillez contacter l'administrateur.
      unauthorized: Le token n'est pas autorisé, peut-être que ce token n'a pas d'abonnement valide. Veuillez contacter l'administrateur.
  novelai.upscale:
    description: Agrandir des images sur IA
    options:
      scale: Mise à l'échelle de
      resolution: Mise à l'échelle à
      crop: Recadrer à la taille avant de l'agrandissement.
      upscaler: Définir l'agrandisseur
      upscaler2: Définir l'agrandisseur 2
      upscaler2visibility: Définir la visibilité de l'agrandisseur 2
      upscaleFirst: Agrandir les images avant de restaurer les visages
    messages:
      expect-image: Attendrez-vous une image.
      download-error: Une erreur d'analyse syntaxique de l'image s'est produite.
      unknown-error: Une erreur inconnue s'est produite.


================================================
FILE: src/locales/ja-JP.yml
================================================
commands:
  novelai:
    description: AI で絵を描く
    usage: |-
      コンマで区切られた英語の生成呪文 (プロンプト) を入力してください。例:1girl, red eyes, black hair。
      モデルに用いられる単語は Danbooru のタグとほとんど同じです。
      興味があったら、レポジトリにスターを付けてください:https://github.com/koishijs/novelai-bot
    options:
      enhance: 向上 (enhance) モードを有効
      model: モデルを指定
      resolution: 画像解像度を設定
      override: デフォルトプロンプトを無効にする
      sampler: サンプラーを指定
      seed: シード値を設定
      steps: ステップ数を設定
      scale: CFG スケール値を設定
      strength: ノイズ除去強度を設定
      noise: ノイズ強度を設定
      hiresFix: 高解像度修正を有効
      undesired: 反対呪文 (ネガティブプロンプト) を設定
      noTranslator: 自動翻訳を無効
      iterations: 画像生成数を設定
    messages:
      exceed-max-iteration: 画像生成数が最大に超えました。
      expect-prompt: 生成呪文を入力してください。
      expect-image: 画像を入力してください。
      latin-only: 英数字だけが入力可能です。
      too-many-words: 入力した単語が多すぎる。
      forbidden-word: 一部の入力した単語が禁止されている。
      concurrent-jobs: |-
        <random>
          <>後でね~今、猫の手も借りたいなの!</>
          <>描けるの、た、タブレットが起動できませんだから。</>
          <>じゃ、まず絵を教えて。</>
        </random>
      waiting: |-
        <random>
          <>私はプロ絵師だから、どんな絵でも描けるの。</>
          <>仕事している…</>
        </random>
      pending: 仕事している前に {0} つの絵が完遂するべきです。
      invalid-size: 向上モードは AI 生成画像のみに用いられる。img2img (指定画像から生成) を使いたければ、「--enhance」を追加せずにコマンドを再実行してください。
      invalid-resolution: 無効な解像度。幅と高さが 64 の倍数である必要があります。
      custom-resolution-unsupported: カスタム画像解像度は使用できません。
      file-too-large: ファイルのサイズが大きすぎる。
      unsupported-file-type: ファイルのタイプがサポートされていません。
      download-error: 画像のダウンロードに失敗しました。
      unknown-error: 不明なエラーが発生しました。
      response-error: 不明なエラーが発生しました ({0})。
      empty-response: サーバーが無効な画像を返されました、後で試してください。
      request-failed: リクエストが失敗しました ({0}),後で試してください。
      request-timeout: リクエストがタイムアウトしました。
      invalid-password: メールアドレスやパスワードが間違っています。
      invalid-token: 期間切れたまたは無効なトークンです。管理者に連絡してください。
      unauthorized: アカウント契約が期間切れるか、トークンが認可されていません。管理者に連絡してください。
  novelai.upscale:
    description: AI で画像拡大
    options:
      scale: 拡大倍率を設定
      resolution: 拡大目標解像度を設定
      crop: 拡大する前に画像をクロップする
      upscaler: 拡大モデルを設定
      upscaler2: 拡大モデル2を設定
      upscaler2visibility: 拡大モデル2の可視度を設定
      upscaleFirst: 拡大する前にフェイス修正を行う
    messages:
      expect-image: 画像を入力してください。
      download-error: 画像のダウンロードに失敗しました。
      unknown-error: 不明なエラーが発生しました。


================================================
FILE: src/locales/zh-CN.yml
================================================
commands:
  novelai:
    description: AI 画图
    usage: |-
      输入用逗号隔开的英文标签,例如 Mr.Quin, dark sword, red eyes。
      查找标签可以使用 Danbooru。
      快来给仓库点个 star 吧:https://github.com/koishijs/novelai-bot

    shortcuts:
      imagine: 画画|约稿
      enhance: 增强

    options:
      enhance: 图片增强模式
      model: 设定生成模型
      resolution: 设定图片尺寸
      override: 禁用默认标签
      sampler: 设置采样器
      seed: 设置随机种子
      steps: 设置迭代步数
      scale: 设置对输入的服从度
      strength: 图片修改幅度
      noise: 图片噪声强度
      hiresFix: 启用高分辨率修复
      undesired: 排除标签
      noTranslator: 禁用自动翻译
      iterations: 设置绘制次数
      batch: 设置绘制批次大小
      smea: 启用 SMEA
      smeaDyn: 启用 DYN
      scheduler: 设置调度器
      decrisper: 启用动态阈值

    messages:
      exceed-max-iteration: 超过最大绘制次数。
      expect-prompt: 请输入标签。
      expect-image: 请输入图片。
      too-many-images: 过多的图片。
      invalid-content: 输入中含有无效内容。
      latin-only: 只接受英文输入。
      too-many-words: 输入的单词数量过多。
      forbidden-word: 输入含有违禁词。
      concurrent-jobs: |-
        <random>
          <>等会再约稿吧,我已经忙不过来了……</>
          <>是数位板没电了,才…才不是我不想画呢!</>
          <>那你得先教我画画(理直气壮</>
        </random>
      waiting: |-
        <random>
          <>少女绘画中……</>
          <>在画了在画了</>
          <>你就在此地不要走动,等我给你画一幅</>
        </random>
      pending: 在画了在画了,不过前面还有 {0} 个稿……
      invalid-size: 增强功能仅适用于被生成的图片。普通的 img2img 请直接使用「约稿」而不是「增强」。
      invalid-resolution: 非法的图片尺寸。宽高必须都为 64 的倍数。
      custom-resolution-unsupported: 不支持自定义图片尺寸。
      file-too-large: 文件体积过大。
      unsupported-file-type: 不支持的文件格式。
      download-error: 图片解析失败。
      unknown-error: 发生未知错误。
      response-error: 发生未知错误 ({0})。
      empty-response: 服务器返回了空白图片,请稍后重试。
      request-failed: 请求失败 ({0}),请稍后重试。
      request-timeout: 请求超时。
      invalid-password: 邮箱或密码错误。
      invalid-token: 令牌无效或已过期,请联系管理员。
      unauthorized: 令牌未授权,可能需要续费,请联系管理员。

  novelai.upscale:
    description: AI 放大图片

    shortcuts:
      upscale: 放大

    options:
      scale: 设置放大倍数
      resolution: 设定放大尺寸
      crop: 是否裁剪以适应尺寸
      upscaler: 设置放大模型
      upscaler2: 设置放大模型 2
      upscaler2visibility: 设置放大模型 2 的可见度
      upscaleFirst: 先放大再执行面部修复

    messages:
      expect-image: 请输入图片。
      download-error: 图片解析失败。
      unknown-error: 发生未知错误。


================================================
FILE: src/locales/zh-TW.yml
================================================
commands:
  novelai:
    description: AI 繪圖
    usage: |-
      輸入以逗號分割的英文提示詞,例如 portrait, blonde hair, red eyes。
      查找可用的提示詞標籤可以使用 Danbooru。
      快來給專案標星收藏吧:https://github.com/koishijs/novelai-bot
    shortcuts:
      imagine: 畫畫|約稿
      enhance: 增強
    options:
      enhance: 圖像增強模式
      model: 設定生成模型
      resolution: 設定圖像尺寸
      override: 禁用預設標籤
      sampler: 設定採樣器
      seed: 設定隨機種子
      steps: 設定迭代步數
      scale: 設定提示詞的相關性
      strength: 圖像修改幅度
      noise: 圖像雜訊強度
      hiresFix: 啟用高分辨率修復
      undesired: 反向提示詞
      noTranslator: 禁用自動翻譯
      iterations: 設定繪畫次數
    messages:
      exceed-max-iteration: 超過最大繪畫次數
      expect-prompt: 請輸入提示詞。
      expect-image: 請輸入圖像。
      latin-only: 僅接受英文提示詞。
      too-many-words: 輸入的提示詞數量過多
      forbidden-word: 提示詞中含有違禁詞彙。
      concurrent-jobs: |-
        <random>
          <>等下再畫吧,我已經忙不過來了……</>
          <>我…我纔不是不會畫畫,只是沒時間!</>
          <>我先喝杯咖啡可以嗎,好睏~</>
        </random>
      waiting: |-
        <random>
          <>少女繪畫中</>
          <>莫行開,我即時來畫!</>
        </random>
      pending: 好酒沉甕底。您還需等我完成前面 {0} 個稿件。
      invalid-size: 增強功能僅適用於 Novel AI 生成圖。若要使用 img2img 功能請直接使用「約稿」而非「增強」。
      invalid-resolution: 圖像尺寸無效。寬度與高度都須爲 64 的倍數。
      custom-resolution-unsupported: 不支援自訂圖像尺寸。
      file-too-large: 文件體積過大。
      unsupported-file-type: 不支援的檔案格式。
      download-error: 圖像解析失敗。
      unknown-error: 發生未知的錯誤。
      response-error: 發生未知的錯誤 ({0})。
      empty-response: 伺服器返回了空圖像,請稍後重試。
      request-failed: 擷取資料失敗 ({0}),請稍後重試。
      request-timeout: 擷取資料超時。
      invalid-password: 電郵地址或密碼不正確。
      invalid-token: 令牌無效或已過期,請聯繫管理員。
      unauthorized: 令牌未經授權,可能關聯帳戶需要續費,請聯繫管理員。
  novelai.upscale:
    description: AI 放大圖像
    shortcuts:
      upscale: 放大
    options:
      scale: 設定放大倍率
      resolution: 設定放大尺寸
      crop: 是否裁剪以適應尺寸
      upscaler: 設定放大模型
      upscaler2: 設定放大模型 2
      upscaler2visibility: 設定放大模型 2 的可視度
      upscaleFirst: 先放大再執行面部修復
    messages:
      expect-image: 請輸入圖像。
      download-error: 圖像解析失敗。
      unknown-error: 發生未知的錯誤。


================================================
FILE: src/types.ts
================================================
export interface Perks {
  maxPriorityActions: number
  startPriority: number
  contextTokens: number
  moduleTrainingSteps: number
  unlimitedMaxPriority: boolean
  voiceGeneration: boolean
  imageGeneration: boolean
  unlimitedImageGeneration: boolean
  unlimitedImageGenerationLimits: {
    resolution: number
    maxPrompts: number
  }[]
}

export interface PaymentProcessorData {
  c: string
  n: number
  o: string
  p: number
  r: string
  s: string
  t: number
  u: string
}

export interface TrainingStepsLeft {
  fixedTrainingStepsLeft: number
  purchasedTrainingSteps: number
}

export interface Subscription {
  tier: number
  active: boolean
  expiresAt: number
  perks: Perks
  paymentProcessorData: PaymentProcessorData
  trainingStepsLeft: TrainingStepsLeft
}

export interface ImageData {
  buffer: ArrayBuffer
  base64: string
  dataUrl: string
}

export namespace NovelAI {
  /** 0.5, 0.5 means make ai choose */
  export interface V4CharacterPromptCenter {
    x: number
    y: number
  }

  export interface V4CharacterPrompt {
    prompt: string
    uc: string
    center: V4CharacterPromptCenter
  }

  export interface V4CharCaption {
    char_caption: string
    centers: V4CharacterPromptCenter[]
  }

  export interface V4PromptCaption {
    base_caption: string
    char_captions: V4CharCaption[]
  }

  export interface V4Prompt {
    caption: V4PromptCaption
  }

  export interface V4PromptPositive extends V4Prompt {
    use_coords: boolean
    use_order: boolean
  }
}

export namespace StableDiffusionWebUI {
  export interface Request {
    prompt: string
    negative_prompt?: string
    enable_hr?: boolean
    denoising_strength?: number
    firstphase_width?: number
    firstphase_height?: number
    styles?: string[]
    seed?: number
    subseed?: number
    subseed_strength?: number
    seed_resize_from_h?: number
    seed_resize_from_w?: number
    batch_size?: number
    n_iter?: number
    steps?: number
    cfg_scale?: number
    width?: number
    height?: number
    restore_faces?: boolean
    tiling?: boolean
    eta?: number
    s_churn?: number
    s_tmax?: number
    s_tmin?: number
    s_noise?: number
    sampler_index?: string
  }

  export interface Response {
    /** Image list in base64 format */
    images: string[]
    parameters: any
    info: any
  }

  /**
   * @see https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/828438b4a190759807f9054932cae3a8b880ddf1/modules/api/models.py#L122
   */
  export interface ExtraSingleImageRequest {
    image: string
    /** Sets the resize mode: 0 to upscale by upscaling_resize amount, 1 to upscale up to upscaling_resize_h x upscaling_resize_w. */
    resize_mode?: 0 | 1
    show_extras_results?: boolean
    gfpgan_visibility?: number // float
    codeformer_visibility?: number // float
    codeformer_weight?: number // float
    upscaling_resize?: number // float
    upscaling_resize_w?: number // int
    upscaling_resize_h?: number // int
    upscaling_crop?: boolean
    upscaler_1?: string
    upscaler_2?: string
    extras_upscaler_2_visibility?: number // float
    upscale_first?: boolean
  }

  export interface ExtraSingleImageResponse {
    image: string
  }
}


================================================
FILE: src/utils.ts
================================================
import { arrayBufferToBase64, Context, Dict, pick, Quester } from 'koishi'
import {
  crypto_generichash, crypto_pwhash,
  crypto_pwhash_ALG_ARGON2ID13, crypto_pwhash_SALTBYTES, ready,
} from 'libsodium-wrappers-sumo'
import imageSize from 'image-size'
import { ImageData, Subscription } from './types'

export function project(object: {}, mapping: {}) {
  const result = {}
  for (const key in mapping) {
    result[key] = object[mapping[key]]
  }
  return result
}

export interface Size {
  width: number
  height: number
}

export function getImageSize(buffer: ArrayBuffer): Size {
  if (typeof Buffer !== 'undefined') {
    return imageSize(new Uint8Array(buffer))
  }
  const blob = new Blob([buffer])
  const image = new Image()
  image.src = URL.createObjectURL(blob)
  return pick(image, ['width', 'height'])
}

const MAX_OUTPUT_SIZE = 1048576
const MAX_CONTENT_SIZE = 10485760
const ALLOWED_TYPES = ['image/jpeg', 'image/png']

export async function download(ctx: Context, url: string, headers = {}): Promise<ImageData> {
  if (url.startsWith('data:') || url.startsWith('file:')) {
    const { mime, data } = await ctx.http.file(url)
    if (!ALLOWED_TYPES.includes(mime)) {
      throw new NetworkError('.unsupported-file-type')
    }
    const base64 = arrayBufferToBase64(data)
    return { buffer: data, base64, dataUrl: `data:${mime};base64,${base64}` }
  } else {
    const image = await ctx.http(url, { responseType: 'arraybuffer', headers })
    if (+image.headers.get('content-length') > MAX_CONTENT_SIZE) {
      throw new NetworkError('.file-too-large')
    }
    const mimetype = image.headers.get('content-type')
    if (!ALLOWED_TYPES.includes(mimetype)) {
      throw new NetworkError('.unsupported-file-type')
    }
    const buffer = image.data
    const base64 = arrayBufferToBase64(buffer)
    return { buffer, base64, dataUrl: `data:${mimetype};base64,${base64}` }
  }
}

export async function calcAccessKey(email: string, password: string) {
  await ready
  return crypto_pwhash(
    64,
    new Uint8Array(Buffer.from(password)),
    crypto_generichash(
      crypto_pwhash_SALTBYTES,
      password.slice(0, 6) + email + 'novelai_data_access_key',
    ),
    2,
    2e6,
    crypto_pwhash_ALG_ARGON2ID13,
    'base64').slice(0, 64)
}

export async function calcEncryptionKey(email: string, password: string) {
  await ready
  return crypto_pwhash(
    128,
    new Uint8Array(Buffer.from(password)),
    crypto_generichash(
      crypto_pwhash_SALTBYTES,
      password.slice(0, 6) + email + 'novelai_data_encryption_key'),
    2,
    2e6,
    crypto_pwhash_ALG_ARGON2ID13,
    'base64')
}

export class NetworkError extends Error {
  constructor(message: string, public params = {}) {
    super(message)
  }

  static catch = (mapping: Dict<string>) => (e: any) => {
    if (Quester.Error.is(e)) {
      const code = e.response?.status
      for (const key in mapping) {
        if (code === +key) {
          throw new NetworkError(mapping[key])
        }
      }
    }
    throw e
  }
}

export async function login(ctx: Context): Promise<string> {
  if (ctx.config.type === 'token') {
    await ctx.http.get<Subscription>(ctx.config.apiEndpoint + '/user/subscription', {
      timeout: 30000,
      headers: { authorization: 'Bearer ' + ctx.config.token },
    }).catch(NetworkError.catch({ 401: '.invalid-token' }))
    return ctx.config.token
  } else if (ctx.config.type === 'login' && process.env.KOISHI_ENV !== 'browser') {
    return ctx.http.post(ctx.config.apiEndpoint + '/user/login', {
      timeout: 30000,
      key: await calcAccessKey(ctx.config.email, ctx.config.password),
    }).catch(NetworkError.catch({ 401: '.invalid-password' })).then(res => res.accessToken)
  } else {
    return ctx.config.token
  }
}

export function closestMultiple(num: number, mult = 64) {
  const floor = Math.floor(num / mult) * mult
  const ceil = Math.ceil(num / mult) * mult
  const closest = num - floor < ceil - num ? floor : ceil
  if (Number.isNaN(closest)) return 0
  return closest <= 0 ? mult : closest
}

export interface Size {
  width: number
  height: number
  /** Indicate whether this resolution is pre-defined or customized */
  custom?: boolean
}

export function resizeInput(size: Size): Size {
  // if width and height produce a valid size, use it
  const { width, height } = size
  if (width % 64 === 0 && height % 64 === 0 && width * height <= MAX_OUTPUT_SIZE) {
    return { width, height }
  }

  // otherwise, set lower size as 512 and use aspect ratio to the other dimension
  const aspectRatio = width / height
  if (aspectRatio > 1) {
    const height = 512
    const width = closestMultiple(height * aspectRatio)
    // check that image is not too large
    if (width * height <= MAX_OUTPUT_SIZE) {
      return { width, height }
    }
  } else {
    const width = 512
    const height = closestMultiple(width / aspectRatio)
    // check that image is not too large
    if (width * height <= MAX_OUTPUT_SIZE) {
      return { width, height }
    }
  }

  // if that fails set the higher size as 1024 and use aspect ratio to the other dimension
  if (aspectRatio > 1) {
    const width = 1024
    const height = closestMultiple(width / aspectRatio)
    return { width, height }
  } else {
    const height = 1024
    const width = closestMultiple(height * aspectRatio)
    return { width, height }
  }
}

export function forceDataPrefix(url: string, mime = 'image/png') {
  // workaround for different gradio versions
  // https://github.com/koishijs/novelai-bot/issues/90
  if (url.startsWith('data:')) return url
  return `data:${mime};base64,` + url
}


================================================
FILE: tests/index.spec.ts
================================================
import { describe, test } from 'node:test'
import * as novelai from '../src'
import { Context } from 'koishi'
import mock from '@koishijs/plugin-mock'

describe('koishi-plugin-novelai', () => {
  test('parse input', () => {
    const ctx = new Context()
    ctx.plugin(mock)
    const session = ctx.bots[0].session({})
    const fork = ctx.plugin(novelai)
    console.log(novelai.parseInput(session, '<lora:skr2:1>,1girl', fork.config, false))
  })
})


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "lib",
    "target": "es2022",
    "module": "esnext",
    "declaration": true,
    "emitDeclarationOnly": true,
    "composite": true,
    "incremental": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "moduleResolution": "bundler"
  },
  "include": [
    "src"
  ]
}

================================================
FILE: vercel.json
================================================
{
  "github": {
    "silent": true
  }
}
Download .txt
gitextract_j9990osm/

├── .editorconfig
├── .gitattributes
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.yaml
│   │   ├── config.yaml
│   │   └── feature-request.yaml
│   └── workflows/
│       ├── build.yml
│       └── stale.yml
├── .gitignore
├── LICENSE
├── build/
│   ├── fetch-horde-models.js
│   └── fetch-sd-samplers.js
├── crowdin.yml
├── data/
│   ├── default-comfyui-i2i-wf.json
│   ├── default-comfyui-t2i-wf.json
│   ├── horde-models.json
│   └── sd-samplers.json
├── docs/
│   ├── .vitepress/
│   │   ├── config.ts
│   │   └── theme/
│   │       └── index.ts
│   ├── config.md
│   ├── faq/
│   │   ├── adapter.md
│   │   └── network.md
│   ├── index.md
│   ├── more.md
│   ├── public/
│   │   └── manifest.json
│   └── usage.md
├── package.json
├── readme.md
├── src/
│   ├── config.ts
│   ├── index.ts
│   ├── locales/
│   │   ├── de-DE.yml
│   │   ├── en-US.yml
│   │   ├── fr-FR.yml
│   │   ├── ja-JP.yml
│   │   ├── zh-CN.yml
│   │   └── zh-TW.yml
│   ├── types.ts
│   └── utils.ts
├── tests/
│   └── index.spec.ts
├── tsconfig.json
└── vercel.json
Download .txt
SYMBOL INDEX (51 symbols across 6 files)

FILE: build/fetch-horde-models.js
  constant MODELS_URL (line 5) | const MODELS_URL = 'https://stablehorde.net/api/v2/status/models'
  constant DATA_JSON_PATH (line 6) | const DATA_JSON_PATH = path.join(__dirname, '..', 'data', 'horde-models....

FILE: build/fetch-sd-samplers.js
  constant API_ROOT (line 5) | const API_ROOT = process.argv[2] || 'http://localhost:7860'
  constant SAMPLERS_ENDPOINT (line 6) | const SAMPLERS_ENDPOINT = '/sdapi/v1/samplers'
  constant DATA_JSON_PATH (line 7) | const DATA_JSON_PATH = path.join(__dirname, '..', 'data', 'sd-samplers.j...

FILE: src/config.ts
  type Model (line 32) | type Model = keyof typeof modelMap
  type Orient (line 33) | type Orient = keyof typeof orientMap
  function createSchema (line 121) | function createSchema(map: Dict<string>) {
  function sd2nai (line 127) | function sd2nai(sampler: string, model: string): string {
  type Options (line 163) | interface Options {
  type PromptConfig (line 175) | interface PromptConfig {
  type FeatureConfig (line 204) | interface FeatureConfig {
  type ParamConfig (line 224) | interface ParamConfig {
  type Config (line 246) | interface Config extends PromptConfig, ParamConfig {
  type Forbidden (line 461) | interface Forbidden {
  function parseForbidden (line 466) | function parseForbidden(input: string) {
  function parseInput (line 483) | function parseInput(session: Session, input: string, config: Config, ove...
  function getWordCount (line 574) | function getWordCount(words: string[]) {

FILE: src/index.ts
  function handleError (line 16) | function handleError({ logger }: Context, session: Session, err: Error) {
  function apply (line 37) | function apply(ctx: Context, config: Config) {

FILE: src/types.ts
  type Perks (line 1) | interface Perks {
  type PaymentProcessorData (line 16) | interface PaymentProcessorData {
  type TrainingStepsLeft (line 27) | interface TrainingStepsLeft {
  type Subscription (line 32) | interface Subscription {
  type ImageData (line 41) | interface ImageData {
  type V4CharacterPromptCenter (line 49) | interface V4CharacterPromptCenter {
  type V4CharacterPrompt (line 54) | interface V4CharacterPrompt {
  type V4CharCaption (line 60) | interface V4CharCaption {
  type V4PromptCaption (line 65) | interface V4PromptCaption {
  type V4Prompt (line 70) | interface V4Prompt {
  type V4PromptPositive (line 74) | interface V4PromptPositive extends V4Prompt {
  type Request (line 81) | interface Request {
  type Response (line 110) | interface Response {
  type ExtraSingleImageRequest (line 120) | interface ExtraSingleImageRequest {
  type ExtraSingleImageResponse (line 138) | interface ExtraSingleImageResponse {

FILE: src/utils.ts
  function project (line 9) | function project(object: {}, mapping: {}) {
  type Size (line 17) | interface Size {
  function getImageSize (line 22) | function getImageSize(buffer: ArrayBuffer): Size {
  constant MAX_OUTPUT_SIZE (line 32) | const MAX_OUTPUT_SIZE = 1048576
  constant MAX_CONTENT_SIZE (line 33) | const MAX_CONTENT_SIZE = 10485760
  constant ALLOWED_TYPES (line 34) | const ALLOWED_TYPES = ['image/jpeg', 'image/png']
  function download (line 36) | async function download(ctx: Context, url: string, headers = {}): Promis...
  function calcAccessKey (line 59) | async function calcAccessKey(email: string, password: string) {
  function calcEncryptionKey (line 74) | async function calcEncryptionKey(email: string, password: string) {
  class NetworkError (line 88) | class NetworkError extends Error {
    method constructor (line 89) | constructor(message: string, public params = {}) {
  function login (line 106) | async function login(ctx: Context): Promise<string> {
  function closestMultiple (line 123) | function closestMultiple(num: number, mult = 64) {
  type Size (line 131) | interface Size {
  function resizeInput (line 138) | function resizeInput(size: Size): Size {
  function forceDataPrefix (line 175) | function forceDataPrefix(url: string, mime = 'image/png') {
Condensed preview — 40 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (118K chars).
[
  {
    "path": ".editorconfig",
    "chars": 147,
    "preview": "root = true\n\n[*]\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_"
  },
  {
    "path": ".gitattributes",
    "chars": 76,
    "preview": "* 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",
    "chars": 1846,
    "preview": "name: Bug Report\ndescription: 提交错误报告\ntitle: \"Bug: \"\nlabels:\n  - bug\nbody:\n  - type: textarea\n    attributes:\n      label"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yaml",
    "chars": 163,
    "preview": "blank_issues_enabled: false\ncontact_links:\n  - name: Discussions\n    url: https://github.com/koishijs/novelai-bot/discus"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yaml",
    "chars": 964,
    "preview": "name: Feature Request\ndescription: 提交功能请求\ntitle: \"Feature: \"\nlabels:\n  - feature\nbody:\n  - type: dropdown\n    id: scope\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "chars": 299,
    "preview": "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"
  },
  {
    "path": ".github/workflows/stale.yml",
    "chars": 629,
    "preview": "name: Stale\n\non:\n  schedule:\n    - cron: 30 7 * * *\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: "
  },
  {
    "path": ".gitignore",
    "chars": 162,
    "preview": "lib\ndist\ncache\n\nnode_modules\nnpm-debug.log\nyarn-debug.log\nyarn-error.log\ntsconfig.tsbuildinfo\n\n.eslintcache\n.DS_Store\n.i"
  },
  {
    "path": "LICENSE",
    "chars": 1081,
    "preview": "MIT License\n\nCopyright (c) 2020-present Shigma & Ninzore\n\nPermission is hereby granted, free of charge, to any person ob"
  },
  {
    "path": "build/fetch-horde-models.js",
    "chars": 649,
    "preview": "const fsp = require('fs/promises')\nconst https = require('https')\nconst path = require('path')\n\nconst MODELS_URL = 'http"
  },
  {
    "path": "build/fetch-sd-samplers.js",
    "chars": 804,
    "preview": "const fsp = require('fs/promises')\nconst http = require('http')\nconst path = require('path')\n\nconst API_ROOT = process.a"
  },
  {
    "path": "crowdin.yml",
    "chars": 163,
    "preview": "pull_request_title: 'i18n: update translations'\npull_request_labels:\n  - i18n\nfiles:\n  - source: /src/locales/zh-CN.yml\n"
  },
  {
    "path": "data/default-comfyui-i2i-wf.json",
    "chars": 1830,
    "preview": "{\n  \"3\": {\n    \"inputs\": {\n      \"seed\": 1,\n      \"steps\": 20,\n      \"cfg\": 8,\n      \"sampler_name\": \"euler\",\n      \"sch"
  },
  {
    "path": "data/default-comfyui-t2i-wf.json",
    "chars": 1638,
    "preview": "{\n  \"3\": {\n    \"inputs\": {\n      \"seed\": 1,\n      \"steps\": 20,\n      \"cfg\": 8,\n      \"sampler_name\": \"euler\",\n      \"sch"
  },
  {
    "path": "data/horde-models.json",
    "chars": 3781,
    "preview": "[\n  \"3DKX\",\n  \"526Mix-Animated\",\n  \"AAM XL\",\n  \"AbsoluteReality\",\n  \"Abyss OrangeMix\",\n  \"AbyssOrangeMix-AfterDark\",\n  \""
  },
  {
    "path": "data/sd-samplers.json",
    "chars": 499,
    "preview": "{\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\":"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "chars": 2060,
    "preview": "import { defineConfig } from '@cordisjs/vitepress'\n\nexport default defineConfig({\n  lang: 'zh-CN',\n  title: 'NovelAI Bot"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "chars": 89,
    "preview": "import { defineTheme } from '@koishijs/vitepress/client'\n\nexport default defineTheme({})\n"
  },
  {
    "path": "docs/config.md",
    "chars": 4153,
    "preview": "# 配置项\n\n## 登录设置\n\n### type\n\n- 类型:`'login' | 'token' | 'naifu' | 'sd-webui' | 'stable-horde'`\n- 默认值:`'token'`\n\n登录方式。`login`"
  },
  {
    "path": "docs/faq/adapter.md",
    "chars": 391,
    "preview": "# 适配器相关\n\n## OneBot\n\n### 消息发送失败,账号可能被风控\n\n如果你刚开始使用 go-cqhttp,建议挂机 3-7 天,即可解除风控。\n\n### 未连接到 go-cqhttp 子进程\n\n请尝试重新下载 gocqhttp "
  },
  {
    "path": "docs/faq/network.md",
    "chars": 955,
    "preview": "# 插件相关\n\n## 功能相关\n\n### 使用这个插件必须花钱吗?\n\n如果你用默认配置,那么是需要的。你也可以选择自己搭建服务器或使用 colab 等,这些方案都是免费的。\n\n## 网络相关\n\n### 请求超时\n\n如果偶尔发生无需在意。如果"
  },
  {
    "path": "docs/index.md",
    "chars": 1912,
    "preview": "# 介绍\n\n基于 [NovelAI](https://novelai.net/) 的画图插件。已实现功能:\n\n- 绘制图片\n- 更改模型、采样器、图片尺寸\n- 高级请求语法\n- 自定义违禁词表\n- 中文关键词自动翻译\n- 发送一段时间后自动"
  },
  {
    "path": "docs/more.md",
    "chars": 693,
    "preview": "# 更多资源\n\n## 公开机器人\n\n[这里](https://github.com/koishijs/novelai-bot/discussions/75) 收集了基于 NovelAI Bot 搭建的公开机器人。大家可以自行前往这些机器人所"
  },
  {
    "path": "docs/public/manifest.json",
    "chars": 332,
    "preview": "{\n  \"name\": \"NovelAI Bot\",\n  \"short_name\": \"NovelAI Bot\",\n  \"description\": \"基于 NovelAI 的画图机器人\",\n  \"start_url\": \"/\",\n  \"d"
  },
  {
    "path": "docs/usage.md",
    "chars": 3964,
    "preview": "# 用法\n\n## 基本用法\n\n### 从文本生成图片 (text2img)\n\n输入「约稿」+ 关键词进行图片绘制。例如:\n\n```text\n约稿 koishi\n```\n\n### 从图片生成图片 (img2img)\n\n输入「约稿」+ 图片 +"
  },
  {
    "path": "package.json",
    "chars": 2233,
    "preview": "{\n  \"name\": \"koishi-plugin-novelai\",\n  \"description\": \"Generate images by diffusion models\",\n  \"version\": \"1.27.0\",\n  \"m"
  },
  {
    "path": "readme.md",
    "chars": 2287,
    "preview": "# [koishi-plugin-novelai](https://bot.novelai.dev)\n\n[![downloads](https://img.shields.io/npm/dm/koishi-plugin-novelai?st"
  },
  {
    "path": "src/config.ts",
    "chars": 20400,
    "preview": "import { Computed, Dict, Schema, Session, Time } from 'koishi'\nimport { Size } from './utils'\n\nconst options: Computed.O"
  },
  {
    "path": "src/index.ts",
    "chars": 31443,
    "preview": "import { Computed, Context, Dict, h, omit, Quester, Session, SessionError, trimSlash } from 'koishi'\nimport { Config, mo"
  },
  {
    "path": "src/locales/de-DE.yml",
    "chars": 1931,
    "preview": "commands:\n  novelai:\n    description: AI 画图\n    usage: |-\n      输入用逗号隔开的英文标签,例如 Mr.Quin, dark sword, red eyes。\n      查找标"
  },
  {
    "path": "src/locales/en-US.yml",
    "chars": 3176,
    "preview": "commands:\n  novelai:\n    description: Generate Images from Novel AI\n    usage: |-\n      Enter \"novelai\" with English pro"
  },
  {
    "path": "src/locales/fr-FR.yml",
    "chars": 4239,
    "preview": "commands:\n  novelai:\n    description: Générer des images sur IA\n    usage: |-\n      Entrez « novelai » avec les descript"
  },
  {
    "path": "src/locales/ja-JP.yml",
    "chars": 2307,
    "preview": "commands:\n  novelai:\n    description: AI で絵を描く\n    usage: |-\n      コンマで区切られた英語の生成呪文 (プロンプト) を入力してください。例:1girl, red eyes,"
  },
  {
    "path": "src/locales/zh-CN.yml",
    "chars": 2212,
    "preview": "commands:\n  novelai:\n    description: AI 画图\n    usage: |-\n      输入用逗号隔开的英文标签,例如 Mr.Quin, dark sword, red eyes。\n      查找标"
  },
  {
    "path": "src/locales/zh-TW.yml",
    "chars": 2031,
    "preview": "commands:\n  novelai:\n    description: AI 繪圖\n    usage: |-\n      輸入以逗號分割的英文提示詞,例如 portrait, blonde hair, red eyes。\n      "
  },
  {
    "path": "src/types.ts",
    "chars": 3204,
    "preview": "export interface Perks {\n  maxPriorityActions: number\n  startPriority: number\n  contextTokens: number\n  moduleTrainingSt"
  },
  {
    "path": "src/utils.ts",
    "chars": 5617,
    "preview": "import { arrayBufferToBase64, Context, Dict, pick, Quester } from 'koishi'\nimport {\n  crypto_generichash, crypto_pwhash,"
  },
  {
    "path": "tests/index.spec.ts",
    "chars": 452,
    "preview": "import { describe, test } from 'node:test'\nimport * as novelai from '../src'\nimport { Context } from 'koishi'\nimport moc"
  },
  {
    "path": "tsconfig.json",
    "chars": 346,
    "preview": "{\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"lib\",\n    \"target\": \"es2022\",\n    \"module\": \"esnext\",\n    "
  },
  {
    "path": "vercel.json",
    "chars": 41,
    "preview": "{\n  \"github\": {\n    \"silent\": true\n  }\n}\n"
  }
]

About this extraction

This page contains the full source code of the koishijs/novelai-bot GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 40 files (108.6 KB), approximately 34.8k tokens, and a symbol index with 51 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!