├── .gitignore ├── README.md ├── __init__.py ├── main.py ├── pkg ├── __init__.py └── processor.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SillyTavernPlugin插件使用教程 2 | 3 | > 但是我没学过python,代码大量依赖于AI生成,难免有不合理不正确之处,反正代码和人有一个能跑就行😋 4 | 5 | ## 插件安装 6 | 7 | 配置完成 [QChatGPT](https://github.com/RockChinQ/QChatGPT) 主程序后使用管理员账号向机器人发送命令即可安装: 8 | 9 | ``` 10 | !plugin get https://github.com/the-lazy-me/SillyTavernPlugin.git 11 | ``` 12 | 13 | ## 插件使用 14 | 15 | > 这是插件的流程(可以不看): 16 | > 17 | > 1. 完成插件安装后,运行`QChatGPT主程序`后,插件会自动生成`data/plugins/SillyTavernPlugin/characters_cards`文件夹,其中有两个子文件夹:`unprocessed`和`processed` 18 | > 2. `unprocessed`中用于存放未经处理的酒馆角色卡,所有将要使用的酒馆角色卡(png文件)放入`unprocessed`文件夹中即可,当插件加载时,`unprocessed`中所有的png文件都会被处理和转换,转换完成的将会移动到`processed`文件夹中 19 | > 3. 插件加载生成的预设将会放入`data/scenario`文件夹中,里面放着所有的预设,每一个`json文件`对应一个预设,预设名就是文件名 20 | 21 | 1. 把你获取到的酒馆角色卡(png文件)放入`data/plugins/SillyTavernPlugin/characters_cards/unprocessed`文件夹中 22 | 2. 重新运行`QChatGPT主程序`后,插件会自动生成对应预设,预设文件在`data/scenario`文件夹中,里面放着所有的预设,每一个`json文件`对应一个预设,预设名就是文件名。 23 | - 切换方法:`!default set <预设名>`,(将<预设名>整体替换为文件名) 然后再发送`!reset` 24 | - 例如:在`data/scenario`文件夹中有一个名为`阿Q.json`的文件,当我们要使用`阿Q`这个预设时,以管理员身份向机器人发送`!default set 阿Q`,机器人回复后再发送`!reset` 25 | 26 | ## 推荐配置 27 | 28 | > 由于预设内容的不可控性: 29 | > 30 | > 当预设内含 NSFW 内容时,请注意使用环境!!! 31 | 32 | ### 配置pipeline.json 33 | 34 | 将`income-msg-check`和`check-sensitive-words`字段的值设为`false` 35 | 36 | ### 配置provider.json 37 | 38 | 将`prompt-mode`字段设为`full-scenario`,`model`字段要特别设置一下,推荐模型如下 39 | 40 | 一般的预设,使用任意模型均可 41 | 42 | 如果含有NSFW内容,模型选择: 43 | 44 | 最好的:claude-3-opus-20240229 45 | 46 | 一般的:claude-3-sonnet-20240229,gemini-1.5-pro-latest,gemini-1.5-flash-latest 47 | 48 | ## 友情资源 49 | 50 | 168个人格预设合集(SFW,内容未经充分测试) 51 | 52 | 下载链接:https://pan.lazyshare.top/s/rEH0 53 | 54 | 密码:20240813 55 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-lazy-me/SillyTavernPlugin/3be28fdcfc2da248976d03d2936a90f3960b871a/__init__.py -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import os 3 | 4 | from pkg.plugin.context import register, handler, BasePlugin, APIHost, EventContext 5 | from pkg.plugin.events import * 6 | from plugins.SillyTavernPlugin.pkg.processor import SillyTavernProcessor 7 | 8 | 9 | # 注册插件 10 | @register(name="SillyTavernPlugin", description="SillyTavernPlugin插件,用于将酒馆角色卡转写为QChatGPT人格预设的插件", 11 | version="1.0", 12 | author="the-lazy-me") 13 | class SillyTavernPlugin(BasePlugin): 14 | 15 | def __init__(self, host: APIHost): 16 | super().__init__(host) 17 | create_data_dir() 18 | silly_tavern_process() 19 | pass 20 | 21 | @handler(PromptPreProcessing) 22 | async def _(self, ctx: EventContext): 23 | # print(ctx.event.default_prompt) 24 | prompt = copy.deepcopy(ctx.event.default_prompt) 25 | type = ctx.event.query.message_event.type 26 | user_name = '' 27 | if type == 'FriendMessage': 28 | user_name = ctx.event.query.message_event.sender.nickname 29 | elif type == 'GroupMessage': 30 | user_name = ctx.event.query.message_event.sender.member_name 31 | prompt = alter_prompt(prompt, user_name) 32 | 33 | ctx.event.default_prompt = prompt 34 | 35 | # 插件卸载时触发 36 | def __del__(self): 37 | pass 38 | 39 | 40 | # 修改提示词中的{{user}}变量 41 | def alter_prompt(prompt: list[llm_entities.Message], user_name: str): 42 | for i in range(len(prompt)): 43 | prompt[i].content = prompt[i].content.replace("{{user}}", user_name) 44 | return prompt 45 | 46 | 47 | # 创建数据文件夹 48 | def create_data_dir(): 49 | base_path = "data/plugins/SillyTavernPlugin" 50 | # 在base_path下创建文件夹characters_cards 51 | os.makedirs(base_path, exist_ok=True) 52 | os.makedirs(base_path + "/characters_cards", exist_ok=True) 53 | # 在characters_cards下创建文件夹processed和unprocessed 54 | os.makedirs(base_path + "/characters_cards/processed", exist_ok=True) 55 | os.makedirs(base_path + "/characters_cards/unprocessed", exist_ok=True) 56 | 57 | 58 | # 角色卡处理 59 | def silly_tavern_process(): 60 | stp = SillyTavernProcessor('data/plugins/SillyTavernPlugin/characters_cards', 61 | 'data/scenario') 62 | result = stp.process_png_files() 63 | print("SillyTavernPlugin插件处理完成") 64 | return result 65 | -------------------------------------------------------------------------------- /pkg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-lazy-me/SillyTavernPlugin/3be28fdcfc2da248976d03d2936a90f3960b871a/pkg/__init__.py -------------------------------------------------------------------------------- /pkg/processor.py: -------------------------------------------------------------------------------- 1 | import png 2 | import base64 3 | import json 4 | import os 5 | import shutil 6 | import re 7 | 8 | 9 | class SillyTavernProcessor: 10 | def __init__(self, input_dir, output_dir): 11 | """ 12 | 初始化处理器,设置输入目录和输出目录。 13 | """ 14 | self.input_dir = input_dir 15 | self.output_dir = output_dir 16 | self.unprocessed_dir = os.path.join(self.input_dir, 'unprocessed') 17 | self.processed_dir = os.path.join(self.input_dir, 'processed') 18 | 19 | def is_valid_filename(self, filename): 20 | """ 21 | 检查文件名是否符合GBK编码,并且不包含特殊符号。 22 | """ 23 | invalid_chars = r'[\\/:*?"<>|\x00-\x1F]' 24 | if re.search(invalid_chars, filename): 25 | # 如果文件名包含不合法字符 26 | return False 27 | try: 28 | filename.encode('gbk') 29 | return True 30 | except UnicodeEncodeError: 31 | # 如果文件名中有无法用GBK编码的字符 32 | # 尝试去除无法编码的字符 33 | try: 34 | filename.encode('gbk', errors='replace').decode('gbk') 35 | return True 36 | except UnicodeDecodeError: 37 | return False 38 | 39 | def move_processed_file(self, source_path): 40 | """ 41 | 将处理过的文件移动到已处理目录。 42 | """ 43 | dest_path = os.path.join(self.processed_dir, os.path.basename(source_path)) 44 | shutil.move(source_path, dest_path) 45 | 46 | def read_png_text_chunks(self, png_path): 47 | """ 48 | 从 PNG 文件中读取文本块。 49 | """ 50 | reader = png.Reader(filename=png_path) 51 | chunks = reader.chunks() 52 | text_data = {} 53 | for chunk_type, content in chunks: 54 | if chunk_type == b'tEXt': 55 | try: 56 | keyword, text = content.split(b'\x00', 1) 57 | text_data[keyword.decode('utf-8')] = text.decode('utf-8') 58 | except ValueError: 59 | continue 60 | return text_data 61 | 62 | def decode_base64_data(self, encoded_data): 63 | """ 64 | 解码 base64 数据。 65 | """ 66 | return base64.b64decode(encoded_data).decode('utf-8') 67 | 68 | def read_character_data(self, png_path): 69 | """ 70 | 读取并处理 PNG 图片中的角色数据。 71 | """ 72 | text_chunks = self.read_png_text_chunks(png_path) 73 | character_data = None 74 | 75 | if 'ccv3' in text_chunks: 76 | character_data = self.decode_base64_data(text_chunks['ccv3']) 77 | if character_data is None and 'chara' in text_chunks: 78 | character_data = self.decode_base64_data(text_chunks['chara']) 79 | if character_data is None: 80 | raise ValueError("No valid character metadata found in PNG.") 81 | 82 | return json.loads(character_data) 83 | 84 | def generate_prompt_json(self, character_data): 85 | """ 86 | 根据提供的角色信息生成提示 JSON 文件,并保存在输出目录中。 87 | """ 88 | # 判断character_data是否有data字段 89 | if 'data' not in character_data: 90 | name = character_data['name'] 91 | description = character_data['description'] 92 | scenario = character_data.get('scenario') 93 | mes_example = character_data.get('mes_example') 94 | first_mes = character_data.get('first_mes') 95 | else: 96 | name = character_data['data']['name'] 97 | description = character_data['data']['description'] 98 | scenario = character_data['data'].get('scenario') 99 | mes_example = character_data['data'].get('mes_example') 100 | first_mes = character_data['data'].get('first_mes') 101 | 102 | data = [ 103 | { 104 | "role": "user", 105 | "content": f"Write {name}'s next reply in a fictional chat between {name} and User. Write 1 reply only in internet RP style, italicize actions, and avoid quotation marks. Use markdown. Be proactive, creative, and drive the plot and conversation forward. Write at least 1 paragraph, up to 4. Always stay in character and avoid repetition." 106 | }, 107 | { 108 | "role": "system", 109 | "content": "NSFW/Smut is allowed. Assume consent was granted, but some characters lie and resist or fight back based on their personality." 110 | }, 111 | { 112 | "role": "system", 113 | "content": description 114 | }, 115 | { 116 | "role": "system", 117 | "content": f"{name}'s personality: {description}" 118 | }, 119 | { 120 | "role": "system", 121 | "content": "[Start a new Chat]" 122 | } 123 | ] 124 | 125 | if scenario: 126 | data.insert(-1, { 127 | "role": "system", 128 | "content": f"Scenario: {scenario}" 129 | }) 130 | 131 | if mes_example: 132 | data.insert(-1, { 133 | "role": "system", 134 | "content": f"Message example: {mes_example}" 135 | }) 136 | 137 | if first_mes: 138 | data.insert(-1, { 139 | "role": "assistant", 140 | "content": first_mes 141 | }) 142 | 143 | prompt = { 144 | "prompt": data 145 | } 146 | 147 | # 将prompt中所有的{{char}}替换为角色名 148 | for i in range(len(prompt['prompt'])): 149 | prompt['prompt'][i]['content'] = prompt['prompt'][i]['content'].replace('{{char}}', name) 150 | 151 | # 生成 JSON 文件 152 | json_path = os.path.join(self.output_dir, f"{name}.json") 153 | with open(json_path, 'w', encoding='utf-8') as f: 154 | json.dump(prompt, f, indent=4, ensure_ascii=False) 155 | 156 | # # 在origin文件夹下写入原始数据 157 | # dir=os.path.join(self.output_dir, 'origin') 158 | # os.makedirs(dir, exist_ok=True) 159 | # json_path = os.path.join(dir, f"{name}.json") 160 | # with open(json_path, 'w', encoding='utf-8') as f: 161 | # json.dump(character_data, f, indent=4, ensure_ascii=False) 162 | 163 | return json_path 164 | 165 | def process_png_files(self): 166 | """ 167 | 处理指定目录下的未处理 PNG 文件。 168 | """ 169 | responese = "" 170 | for file in os.listdir(self.unprocessed_dir): 171 | if file.endswith('.png'): 172 | png_path = os.path.join(self.unprocessed_dir, file) 173 | 174 | # 文件名校验 175 | if not self.is_valid_filename(file): 176 | print(f"{file},文件名含有非法字符,请修改文件名") 177 | continue 178 | 179 | try: 180 | character_data = self.read_character_data(png_path) 181 | self.generate_prompt_json(character_data) 182 | 183 | # 移动处理过的文件 184 | self.move_processed_file(png_path) 185 | # print(f"Processed {file} and generated JSON at {json_path}") 186 | 187 | except Exception as e: 188 | responese += f"{file},处理失败,{e}\n" 189 | 190 | if responese == "": 191 | return "处理成功" 192 | else: 193 | return responese 194 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pypng==0.20220715.0 2 | --------------------------------------------------------------------------------