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: - Adapter: - Node version: - Koishi version: 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` - 默认值:官服的 `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` 连接失败时最大的重试次数。 ### maxIterations - 类型:`number` - 默认值:`1` 允许的最大绘制次数。参见 [批量生成](./usage.md#批量生成)。 ### maxConcurrency - 类型:`number` - 默认值:`0` 单个频道下的最大并发数量 (设置为 `0` 以禁用此功能)。 ================================================ FILE: docs/faq/adapter.md ================================================ # 适配器相关 ## OneBot ### 消息发送失败,账号可能被风控 如果你刚开始使用 go-cqhttp,建议挂机 3-7 天,即可解除风控。 ### 未连接到 go-cqhttp 子进程 请尝试重新下载 gocqhttp 插件。 ### 选择登陆方式的按钮存在但点击无效 已知问题: ## Discord ### 连接失败:disallowed intent(s) 解决方案: ## Telegram ### 群组消息不接收 解决方案: ================================================ 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 ", "contributors": [ "Shigma ", { "name": "Maiko Tan", "email": "maiko.tan.coding@gmail.com" }, "Ninzore " ], "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) | ## 使用教程 搭建教程、使用方法、参数配置、常见问题请见: ## 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 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) { 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 negativePrompt?: Computed forbidden?: Computed defaultPromptSw?: boolean defaultPrompt?: Computed placement?: Computed<'before' | 'after'> latinOnly?: Computed translator?: boolean lowerCase?: boolean maxWords?: Computed } export const PromptConfig: Schema = 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 text?: Computed image?: Computed upscale?: Computed } 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 decrisper?: boolean upscaler?: string restoreFaces?: boolean hiresFix?: boolean hiresFixUpscaler: string scale?: Computed textSteps?: Computed imageSteps?: Computed maxSteps?: Computed strength?: Computed noise?: Computed resolution?: Computed maxResolution?: Computed } export interface Config extends PromptConfig, ParamConfig { type: 'token' | 'login' | 'naifu' | 'sd-webui' | 'stable-horde' | 'comfyui' token?: string email?: string password?: string authLv?: Computed authLvDefault?: Computed output?: Computed<'minimal' | 'default' | 'verbose'> features?: FeatureConfig apiEndpoint?: string endpoint?: string headers?: Dict 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 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((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> = Object.create(null) const globalTasks = new Set() let tokenTask: Promise = null const getToken = () => tokenTask ||= login(ctx) ctx.accept(['token', 'type', 'email', 'password'], () => tokenTask = null) type HiddenCallback = (session: Session<'authority'>) => boolean const useFilter = (filter: Computed): 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 ') .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 ', { type: models, hidden: thirdParty }) .option('resolution', '-r ', { type: resolution }) .option('output', '-o', { type: ['minimal', 'default', 'verbose'] }) .option('override', '-O', { hidden: restricted }) .option('sampler', '-s ') .option('seed', '-x ') .option('steps', '-t ', { type: step, hidden: restricted }) .option('scale', '-c ') .option('noise', '-n ', { hidden: some(restricted, thirdParty) }) .option('strength', '-N ', { hidden: restricted }) .option('hiresFix', '-H', { hidden: () => config.type !== 'sd-webui' }) .option('hiresFixSteps', '', { 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 ', { 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 ') .option('noTranslator', '-T', { hidden: () => !ctx.translator || !config.translator }) .option('iterations', '-i ', { fallback: 1, hidden: () => config.maxIterations <= 1 }) .option('batch', '-b ', { 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 ', { fallback: 2 }) .option('resolution', '-r ', { type: resolution }) .option('crop', '-C, --no-crop', { value: false, fallback: true }) .option('upscaler', '-1 ', { type: upscalers }) .option('upscaler2', '-2 ', { type: upscalers }) .option('visibility', '-v ') .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(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: |- <>等会再约稿吧,我已经忙不过来了…… <>是数位板没电了,才…才不是我不想画呢! <>那你得先教我画画(理直气壮 waiting: |- <>少女绘画中…… <>在画了在画了 <>你就在此地不要走动,等我给你画一幅 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: |- <>Too busy to handle your request... <>Brb power nap :zzz: <>(*~*) Have no time to draw a new one. waiting: |- <>The illustrator starts painting. <>Monet and Da Vinci, whose style is better for this? pending: "\n <>Sure.\n <>Sure, but please wait for me to complete this one before.\n <>Bruh, there are {0} jobs pending!\n" 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: |- <>Trop occupé pour répondre à votre demande... <>Courte sieste :zzz: <>(*~*) Pas le temps d'en dessiner un nouveau. waiting: |- <>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 ? pending: "\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" 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: |- <>後でね~今、猫の手も借りたいなの! <>描けるの、た、タブレットが起動できませんだから。 <>じゃ、まず絵を教えて。 waiting: |- <>私はプロ絵師だから、どんな絵でも描けるの。 <>仕事している… 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: |- <>等会再约稿吧,我已经忙不过来了…… <>是数位板没电了,才…才不是我不想画呢! <>那你得先教我画画(理直气壮 waiting: |- <>少女绘画中…… <>在画了在画了 <>你就在此地不要走动,等我给你画一幅 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: |- <>等下再畫吧,我已經忙不過來了…… <>我…我纔不是不會畫畫,只是沒時間! <>我先喝杯咖啡可以嗎,好睏~ waiting: |- <>少女繪畫中 <>莫行開,我即時來畫! 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 { 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) => (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 { if (ctx.config.type === 'token') { await ctx.http.get(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, ',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 } }