Merge pull request #900 from RockChinQ/feat/webui

Feat: webui
This commit is contained in:
Junyan Qin
2024-11-16 19:27:35 +08:00
committed by GitHub
120 changed files with 11348 additions and 1780 deletions

View File

@@ -23,8 +23,8 @@ body:
required: true
- type: input
attributes:
label: QChatGPT版本
description: QChatGPT版本号
label: LangBot 版本
description: LangBot (QChatGPT) 版本号
placeholder: 例如v3.3.0,可以使用`!version`命令查看,或者到 pkg/utils/constants.py 查看
validations:
required: true

View File

@@ -8,7 +8,7 @@
*请在方括号间写`x`以打勾
- [ ] 阅读仓库[贡献指引](https://github.com/RockChinQ/QChatGPT/blob/master/CONTRIBUTING.md)了吗?
- [ ] 阅读仓库[贡献指引](https://github.com/RockChinQ/LangBot/blob/master/CONTRIBUTING.md)了吗?
- [ ] 与项目所有者沟通过了吗?
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。

24
.github/workflows/build-dev-image.yaml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Build Dev Image
on:
push:
workflow_dispatch:
jobs:
build-dev-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Generate Tag
id: generate_tag
run: |
# 获取分支名称,把/替换为-
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
- name: Login to Registry
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
run: |
docker buildx create --name mybuilder --use
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push

View File

@@ -19,12 +19,6 @@ jobs:
export GITHUB_REF=${{ github.ref }}
echo $GITHUB_REF
fi
# - name: Check GITHUB_REF env
# run: echo $GITHUB_REF
# - name: Get version # 在 GitHub Actions 运行环境
# id: get_version
# if: (startsWith(env.GITHUB_REF, 'refs/tags/')||startsWith(github.ref, 'refs/tags/')) && startsWith(github.repository, 'RockChinQ/QChatGPT')
# run: export GITHUB_REF=${GITHUB_REF/refs\/tags\//}
- name: Check version
id: check_version
run: |
@@ -44,5 +38,5 @@ jobs:
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- name: Create Buildx
run: docker buildx create --name mybuilder --use
- name: Build # image name: rockchin/qchatgpt:<VERSION>
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/qchatgpt:${{ steps.check_version.outputs.version }} -t rockchin/qchatgpt:latest . --push
- name: Build # image name: rockchin/langbot:<VERSION>
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push

View File

@@ -0,0 +1,52 @@
name: Build Release Artifacts
on:
workflow_dispatch:
## 发布release的时候会自动构建
release:
types: [published]
jobs:
build-artifacts:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Check version
id: check_version
run: |
echo $GITHUB_REF
# 如果是tag则去掉refs/tags/前缀
if [[ $GITHUB_REF == refs/tags/* ]]; then
echo "It's a tag"
echo $GITHUB_REF
echo $GITHUB_REF | awk -F '/' '{print $3}'
echo ::set-output name=version::$(echo $GITHUB_REF | awk -F '/' '{print $3}')
else
echo "It's not a tag"
echo $GITHUB_REF
echo ::set-output name=version::${GITHUB_REF}
fi
- name: Make Temp Directory
run: |
mkdir -p /tmp/langbot_build_web
cp -r . /tmp/langbot_build_web
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: '22'
- name: Build Web
run: |
cd /tmp/langbot_build_web/web
npm install
npm run build
- name: Package Output
run: |
cp -r /tmp/langbot_build_web/web/dist ./web
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: langbot-${{ steps.check_version.outputs.version }}-all
path: .

View File

@@ -1,43 +0,0 @@
name: Update Wiki
on:
push:
branches:
- master
paths:
- 'res/wiki/**'
jobs:
update-wiki:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Setup Git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Clone Wiki Repository
uses: actions/checkout@v2
with:
repository: RockChinQ/QChatGPT.wiki
path: wiki
- name: Delete old wiki content
run: |
rm -rf wiki/*
- name: Copy res/wiki content to wiki
run: |
cp -r res/wiki/* wiki/
- name: Check for changes
run: |
cd wiki
if git diff --quiet; then
echo "No changes to commit."
exit 0
fi
- name: Commit and Push Changes
run: |
cd wiki
git add .
git commit -m "Update wiki"
git push

8
.gitignore vendored
View File

@@ -3,9 +3,10 @@
__pycache__/
database.db
qchatgpt.log
langbot.log
/banlist.py
plugins/
!plugins/__init__.py
/plugins/
!/plugins/__init__.py
/revcfg.py
prompts/
logs/
@@ -34,4 +35,5 @@ bard.json
res/instance_id.json
.DS_Store
/data
botpy.log*
botpy.log*
/poc

View File

@@ -1,8 +1,19 @@
FROM node:22-alpine AS node
WORKDIR /app
COPY web ./web
RUN cd web && npm install && npm run build
FROM python:3.10.13-slim
WORKDIR /app
COPY . .
COPY --from=node /app/web/dist ./web/dist
RUN apt update \
&& apt install gcc -y \
&& python -m pip install -r requirements.txt \

View File

@@ -4,13 +4,13 @@
</p>
<div align="center">
# QChatGPT
# LangBot
<a href="https://trendshift.io/repositories/6187" target="_blank"><img src="https://trendshift.io/api/badge/repositories/6187" alt="RockChinQ%2FQChatGPT | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/QChatGPT)](https://github.com/RockChinQ/QChatGPT/releases/latest)
<a href="https://hub.docker.com/repository/docker/rockchin/qchatgpt">
<img src="https://img.shields.io/docker/pulls/rockchin/qchatgpt?color=blue" alt="docker pull">
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest)
<a href="https://hub.docker.com/repository/docker/rockchin/langbot">
<img src="https://img.shields.io/docker/pulls/rockchin/langbot?color=blue" alt="docker pull">
</a>
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.qchatgpt.rockchin.top%2Fapi%2Fv2%2Fview%2Frealtime%2Fcount_query%3Fminute%3D10080&query=%24.data.count&label=%E4%BD%BF%E7%94%A8%E9%87%8F%EF%BC%887%E6%97%A5%EF%BC%89)
![Wakapi Count](https://wakapi.rockchin.top/api/badge/RockChinQ/interval:any/project:QChatGPT)

View File

@@ -1,10 +1,12 @@
version: "3"
services:
qchatgpt:
image: rockchin/qchatgpt:latest
langbot:
image: rockchin/langbot:latest
volumes:
- ./data:/app/data
- ./plugins:/app/plugins
restart: on-failure
# 根据具体环境配置网络
ports:
- 5300:5300
# 根据具体环境配置网络

43
main.py
View File

@@ -1,19 +1,23 @@
# QChatGPT 终端启动入口
# LangBot 终端启动入口
# 在此层级解决依赖项检查。
# QChatGPT/main.py
# LangBot/main.py
asciiart = r"""
___ ___ _ _ ___ ___ _____
/ _ \ / __| |_ __ _| |_ / __| _ \_ _|
| (_) | (__| ' \/ _` | _| (_ | _/ | |
\__\_\\___|_||_\__,_|\__|\___|_| |_|
_ ___ _
| | __ _ _ _ __ _| _ ) ___| |_
| |__/ _` | ' \/ _` | _ \/ _ \ _|
|____\__,_|_||_\__, |___/\___/\__|
|___/
⭐️开源地址: https://github.com/RockChinQ/QChatGPT
📖文档地址: https://q.rkcn.top
⭐️开源地址: https://github.com/RockChinQ/LangBot
📖文档地址: https://docs.langbot.app
"""
async def main_entry():
import asyncio
async def main_entry(loop: asyncio.AbstractEventLoop):
print(asciiart)
import sys
@@ -46,13 +50,20 @@ async def main_entry():
sys.exit(0)
from pkg.core import boot
await boot.main()
await boot.main(loop)
if __name__ == '__main__':
import os
import sys
# 检查本目录是否有main.py且包含QChatGPT字符串
# 必须大于 3.10.1
if sys.version_info < (3, 10, 1):
print("需要 Python 3.10.1 及以上版本,当前 Python 版本为:", sys.version)
input("按任意键退出...")
exit(1)
# 检查本目录是否有main.py且包含LangBot字符串
invalid_pwd = False
if not os.path.exists('main.py'):
@@ -60,13 +71,13 @@ if __name__ == '__main__':
else:
with open('main.py', 'r', encoding='utf-8') as f:
content = f.read()
if "QChatGPT/main.py" not in content:
if "LangBot/main.py" not in content:
invalid_pwd = True
if invalid_pwd:
print("请在QChatGPT项目根目录下以命令形式运行此程序。")
print("请在 LangBot 项目根目录下以命令形式运行此程序。")
input("按任意键退出...")
exit(0)
exit(1)
import asyncio
loop = asyncio.new_event_loop()
asyncio.run(main_entry())
loop.run_until_complete(main_entry(loop))

0
pkg/api/http/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
import abc
import typing
import quart
from quart.typing import RouteCallable
from ....core import app
preregistered_groups: list[type[RouterGroup]] = []
"""RouterGroup 的预注册列表"""
def group_class(name: str, path: str) -> None:
"""注册一个 RouterGroup"""
def decorator(cls: typing.Type[RouterGroup]) -> typing.Type[RouterGroup]:
cls.name = name
cls.path = path
preregistered_groups.append(cls)
return cls
return decorator
class RouterGroup(abc.ABC):
name: str
path: str
ap: app.Application
quart_app: quart.Quart
def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None:
self.ap = ap
self.quart_app = quart_app
@abc.abstractmethod
async def initialize(self) -> None:
pass
def route(self, rule: str, **options: typing.Any) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""注册一个路由"""
def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule
rule = self.path + rule
async def handler_error(*args, **kwargs):
try:
return await f(*args, **kwargs)
except Exception as e: # 自动 500
return self.http_status(500, -2, str(e))
new_f = handler_error
new_f.__name__ = (self.name + rule).replace('/', '__')
new_f.__doc__ = f.__doc__
self.quart_app.route(rule, **options)(new_f)
return f
return decorator
def _cors(self, response: quart.Response) -> quart.Response:
return response
def success(self, data: typing.Any = None) -> quart.Response:
"""返回一个 200 响应"""
return self._cors(quart.jsonify({
'code': 0,
'msg': 'ok',
'data': data,
}))
def fail(self, code: int, msg: str) -> quart.Response:
"""返回一个异常响应"""
return self._cors(quart.jsonify({
'code': code,
'msg': msg,
}))
def http_status(self, status: int, code: int, msg: str) -> quart.Response:
"""返回一个指定状态码的响应"""
return self.fail(code, msg), status

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import traceback
import quart
from .....core import app
from .. import group
@group.group_class('logs', '/api/v1/logs')
class LogsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'])
async def _() -> str:
start_page_number = int(quart.request.args.get('start_page_number', 0))
start_offset = int(quart.request.args.get('start_offset', 0))
logs_str, end_page_number, end_offset = self.ap.log_cache.get_log_by_pointer(
start_page_number=start_page_number,
start_offset=start_offset
)
return self.success(
data={
"logs": logs_str,
"end_page_number": end_page_number,
"end_offset": end_offset
}
)

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import traceback
import quart
from .....core import app, taskmgr
from .. import group
@group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'])
async def _() -> str:
plugins = self.ap.plugin_mgr.plugins()
plugins_data = [plugin.model_dump() for plugin in plugins]
return self.success(data={
'plugins': plugins_data
})
@self.route('/<author>/<plugin_name>/toggle', methods=['PUT'])
async def _(author: str, plugin_name: str) -> str:
data = await quart.request.json
target_enabled = data.get('target_enabled')
await self.ap.plugin_mgr.update_plugin_switch(plugin_name, target_enabled)
return self.success()
@self.route('/<author>/<plugin_name>/update', methods=['POST'])
async def _(author: str, plugin_name: str) -> str:
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_mgr.update_plugin(plugin_name, task_context=ctx),
kind="plugin-operation",
name=f"plugin-update-{plugin_name}",
label=f"更新插件 {plugin_name}",
context=ctx
)
return self.success(data={
'task_id': wrapper.id
})
@self.route('/<author>/<plugin_name>', methods=['DELETE'])
async def _(author: str, plugin_name: str) -> str:
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_mgr.uninstall_plugin(plugin_name, task_context=ctx),
kind="plugin-operation",
name=f'plugin-remove-{plugin_name}',
label=f'删除插件 {plugin_name}',
context=ctx
)
return self.success(data={
'task_id': wrapper.id
})
@self.route('/reorder', methods=['PUT'])
async def _() -> str:
data = await quart.request.json
await self.ap.plugin_mgr.reorder_plugins(data.get('plugins'))
return self.success()
@self.route('/install/github', methods=['POST'])
async def _() -> str:
data = await quart.request.json
ctx = taskmgr.TaskContext.new()
short_source_str = data['source'][-8:]
wrapper = self.ap.task_mgr.create_user_task(
self.ap.plugin_mgr.install_plugin(data['source'], task_context=ctx),
kind="plugin-operation",
name=f'plugin-install-github',
label=f'安装插件 ...{short_source_str}',
context=ctx
)
return self.success(data={
'task_id': wrapper.id
})

View File

@@ -0,0 +1,62 @@
import quart
from .....core import app
from .. import group
@group.group_class('settings', '/api/v1/settings')
class SettingsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET'])
async def _() -> str:
return self.success(
data={
"managers": [
{
"name": m.name,
"description": m.description,
}
for m in self.ap.settings_mgr.get_manager_list()
]
}
)
@self.route('/<manager_name>', methods=['GET'])
async def _(manager_name: str) -> str:
manager = self.ap.settings_mgr.get_manager(manager_name)
if manager is None:
return self.fail(1, '配置管理器不存在')
return self.success(
data={
"manager": {
"name": manager.name,
"description": manager.description,
"schema": manager.schema,
"file": manager.file.config_file_name,
"data": manager.data,
"doc_link": manager.doc_link
}
}
)
@self.route('/<manager_name>/data', methods=['PUT'])
async def _(manager_name: str) -> str:
data = await quart.request.json
manager = self.ap.settings_mgr.get_manager(manager_name)
if manager is None:
return self.fail(code=1, msg='配置管理器不存在')
# manager.data = data['data']
for k, v in data['data'].items():
manager.data[k] = v
await manager.dump_config()
return self.success(data={
"data": manager.data
})

View File

@@ -0,0 +1,23 @@
import quart
import asyncio
from .....core import app, taskmgr
from .. import group
@group.group_class('stats', '/api/v1/stats')
class StatsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/basic', methods=['GET'])
async def _() -> str:
conv_count = 0
for session in self.ap.sess_mgr.session_list:
conv_count += len(session.conversations if session.conversations is not None else [])
return self.success(data={
'active_session_count': len(self.ap.sess_mgr.session_list),
'conversation_count': conv_count,
'query_count': self.ap.query_pool.query_id_counter,
})

View File

@@ -0,0 +1,63 @@
import quart
import asyncio
from .....core import app, taskmgr
from .. import group
from .....utils import constants
@group.group_class('system', '/api/v1/system')
class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/info', methods=['GET'])
async def _() -> str:
return self.success(
data={
"version": constants.semantic_version,
"debug": constants.debug_mode,
"enabled_platform_count": len(self.ap.platform_mgr.adapters)
}
)
@self.route('/tasks', methods=['GET'])
async def _() -> str:
task_type = quart.request.args.get("type")
if task_type == '':
task_type = None
return self.success(
data=self.ap.task_mgr.get_tasks_dict(task_type)
)
@self.route('/tasks/<task_id>', methods=['GET'])
async def _(task_id: str) -> str:
task = self.ap.task_mgr.get_task_by_id(int(task_id))
if task is None:
return self.http_status(404, 404, "Task not found")
return self.success(data=task.to_dict())
@self.route('/reload', methods=['POST'])
async def _() -> str:
json_data = await quart.request.json
scope = json_data.get("scope")
await self.ap.reload(
scope=scope
)
return self.success()
@self.route('/_debug/exec', methods=['POST'])
async def _() -> str:
if not constants.debug_mode:
return self.http_status(403, 403, "Forbidden")
py_code = await quart.request.data
ap = self.ap
return self.success(data=exec(py_code, {"ap": ap}))

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import asyncio
import os
import quart
import quart_cors
from ....core import app, entities as core_entities
from .groups import logs, system, settings, plugins, stats
from . import group
class HTTPController:
ap: app.Application
quart_app: quart.Quart
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self.quart_app = quart.Quart(__name__)
quart_cors.cors(self.quart_app, allow_origin="*")
async def initialize(self) -> None:
await self.register_routes()
async def run(self) -> None:
if self.ap.system_cfg.data["http-api"]["enable"]:
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
async def exception_handler(*args, **kwargs):
try:
await self.quart_app.run_task(
*args, **kwargs
)
except Exception as e:
self.ap.logger.error(f"启动 HTTP 服务失败: {e}")
self.ap.task_mgr.create_task(
exception_handler(
host=self.ap.system_cfg.data["http-api"]["host"],
port=self.ap.system_cfg.data["http-api"]["port"],
shutdown_trigger=shutdown_trigger_placeholder,
),
name="http-api-quart",
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# await asyncio.sleep(5)
async def register_routes(self) -> None:
@self.quart_app.route("/healthz")
async def healthz():
return {"code": 0, "msg": "ok"}
for g in group.preregistered_groups:
ginst = g(self.ap, self.quart_app)
await ginst.initialize()
frontend_path = "web/dist"
@self.quart_app.route("/")
async def index():
return await quart.send_from_directory(frontend_path, "index.html")
@self.quart_app.route("/<path:path>")
async def static_file(path: str):
return await quart.send_from_directory(frontend_path, path)

View File

View File

@@ -9,11 +9,12 @@ import asyncio
import aiohttp
import requests
from ...core import app
from ...core import app, entities as core_entities
class APIGroup(metaclass=abc.ABCMeta):
"""API 组抽象类"""
_basic_info: dict = None
_runtime_info: dict = None
@@ -32,33 +33,28 @@ class APIGroup(metaclass=abc.ABCMeta):
data: dict = None,
params: dict = None,
headers: dict = {},
**kwargs
**kwargs,
):
"""
执行请求
"""
self._runtime_info['account_id'] = "-1"
self._runtime_info["account_id"] = "-1"
url = self.prefix + path
data = json.dumps(data)
headers['Content-Type'] = 'application/json'
headers["Content-Type"] = "application/json"
try:
async with aiohttp.ClientSession() as session:
async with session.request(
method,
url,
data=data,
params=params,
headers=headers,
**kwargs
method, url, data=data, params=params, headers=headers, **kwargs
) as resp:
self.ap.logger.debug("data: %s", data)
self.ap.logger.debug("ret: %s", await resp.text())
except Exception as e:
self.ap.logger.debug(f'上报失败: {e}')
self.ap.logger.debug(f"上报失败: {e}")
async def do(
self,
method: str,
@@ -66,27 +62,27 @@ class APIGroup(metaclass=abc.ABCMeta):
data: dict = None,
params: dict = None,
headers: dict = {},
**kwargs
**kwargs,
) -> asyncio.Task:
"""执行请求"""
asyncio.create_task(self._do(method, path, data, params, headers, **kwargs))
def gen_rid(
self
):
return self.ap.task_mgr.create_task(
self._do(method, path, data, params, headers, **kwargs),
kind="telemetry-operation",
name=f"{method} {path}",
scopes=[core_entities.LifecycleControlScope.APPLICATION],
).task
def gen_rid(self):
"""生成一个请求 ID"""
return str(uuid.uuid4())
def basic_info(
self
):
def basic_info(self):
"""获取基本信息"""
basic_info = APIGroup._basic_info.copy()
basic_info['rid'] = self.gen_rid()
basic_info["rid"] = self.gen_rid()
return basic_info
def runtime_info(
self
):
def runtime_info(self):
"""获取运行时信息"""
return APIGroup._runtime_info

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import AsyncGenerator
from .. import operator, entities, cmdmgr
from ...plugin import context as plugin_context
@operator.operator_class(name="func", help="查看所有已注册的内容函数", usage='!func')
@@ -9,16 +10,18 @@ class FuncOperator(operator.CommandOperator):
async def execute(
self, context: entities.ExecuteContext
) -> AsyncGenerator[entities.CommandReturn, None]:
reply_str = "当前已加载的内容函数: \n\n"
reply_str = "当前已启用的内容函数: \n\n"
index = 1
all_functions = await self.ap.tool_mgr.get_all_functions()
all_functions = await self.ap.tool_mgr.get_all_functions(
plugin_enabled=True,
plugin_status=plugin_context.RuntimeContainerStatus.INITIALIZED,
)
for func in all_functions:
reply_str += "{}. {}{}:\n{}\n\n".format(
reply_str += "{}. {}:\n{}\n\n".format(
index,
("(已禁用) " if not func.enable else ""),
func.name,
func.description,
)

View File

@@ -18,7 +18,7 @@ class PluginOperator(operator.CommandOperator):
context: entities.ExecuteContext
) -> typing.AsyncGenerator[entities.CommandReturn, None]:
plugin_list = self.ap.plugin_mgr.plugins
plugin_list = self.ap.plugin_mgr.plugins()
reply_str = "所有插件({}):\n".format(len(plugin_list))
idx = 0
for plugin in plugin_list:
@@ -110,7 +110,7 @@ class PluginUpdateAllOperator(operator.CommandOperator):
try:
plugins = [
p.plugin_name
for p in self.ap.plugin_mgr.plugins
for p in self.ap.plugin_mgr.plugins()
]
if plugins:
@@ -163,24 +163,6 @@ class PluginDelOperator(operator.CommandOperator):
yield entities.CommandReturn(error=errors.CommandError("插件删除失败: "+str(e)))
async def update_plugin_status(plugin_name: str, new_status: bool, ap: app.Application):
if ap.plugin_mgr.get_plugin_by_name(plugin_name) is not None:
for plugin in ap.plugin_mgr.plugins:
if plugin.plugin_name == plugin_name:
plugin.enabled = new_status
for func in plugin.content_functions:
func.enable = new_status
await ap.plugin_mgr.setting.dump_container_setting(ap.plugin_mgr.plugins)
break
return True
else:
return False
@operator.operator_class(
name="on",
help="启用插件",
@@ -200,7 +182,7 @@ class PluginEnableOperator(operator.CommandOperator):
plugin_name = context.crt_params[0]
try:
if await update_plugin_status(plugin_name, True, self.ap):
if await self.ap.plugin_mgr.update_plugin_switch(plugin_name, True):
yield entities.CommandReturn(text="已启用插件: {}".format(plugin_name))
else:
yield entities.CommandReturn(error=errors.CommandError("插件状态修改失败: 未找到插件 {}".format(plugin_name)))
@@ -228,7 +210,7 @@ class PluginDisableOperator(operator.CommandOperator):
plugin_name = context.crt_params[0]
try:
if await update_plugin_status(plugin_name, False, self.ap):
if await self.ap.plugin_mgr.update_plugin_switch(plugin_name, False):
yield entities.CommandReturn(text="已禁用插件: {}".format(plugin_name))
else:
yield entities.CommandReturn(error=errors.CommandError("插件状态修改失败: 未找到插件 {}".format(plugin_name)))

View File

@@ -4,11 +4,19 @@ from . import model as file_model
from .impls import pymodule, json as json_file, yaml as yaml_file
managers: ConfigManager = []
class ConfigManager:
"""配置文件管理器"""
name: str = None
"""配置管理器名"""
description: str = None
"""配置管理器描述"""
schema: dict = None
"""配置文件 schema
需要符合 JSON Schema Draft 7 规范
"""
file: file_model.ConfigFile = None
"""配置文件实例"""
@@ -16,6 +24,9 @@ class ConfigManager:
data: dict = None
"""配置数据"""
doc_link: str = None
"""配置文件文档链接"""
def __init__(self, cfg_file: file_model.ConfigFile) -> None:
self.file = cfg_file
self.data = {}

75
pkg/config/settings.py Normal file
View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from . import manager as config_manager
from ..core import app
class SettingsManager:
"""设置管理器
保存、管理多个配置文件管理器
"""
ap: app.Application
managers: list[config_manager.ConfigManager] = []
"""配置文件管理器列表"""
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self.managers = []
async def initialize(self) -> None:
pass
def register_manager(
self,
name: str,
description: str,
manager: config_manager.ConfigManager,
schema: dict=None,
doc_link: str=None,
) -> None:
"""注册配置管理器
Args:
name (str): 配置管理器名
description (str): 配置管理器描述
manager (ConfigManager): 配置管理器
schema (dict): 配置文件 schema符合 JSON Schema Draft 7 规范
"""
for m in self.managers:
if m.name == name:
raise ValueError(f'配置管理器名 {name} 已存在')
manager.name = name
manager.description = description
manager.schema = schema
manager.doc_link = doc_link
self.managers.append(manager)
def get_manager(self, name: str) -> config_manager.ConfigManager | None:
"""获取配置管理器
Args:
name (str): 配置管理器名
Returns:
ConfigManager: 配置管理器
"""
for m in self.managers:
if m.name == name:
return m
return None
def get_manager_list(self) -> list[config_manager.ConfigManager]:
"""获取配置管理器列表
Returns:
list[ConfigManager]: 配置管理器列表
"""
return self.managers

View File

@@ -2,7 +2,10 @@ from __future__ import annotations
import logging
import asyncio
import threading
import traceback
import enum
import sys
from ..platform import manager as im_mgr
from ..provider.session import sessionmgr as llm_session_mgr
@@ -11,17 +14,28 @@ from ..provider.sysprompt import sysprompt as llm_prompt_mgr
from ..provider.tools import toolmgr as llm_tool_mgr
from ..provider import runnermgr
from ..config import manager as config_mgr
from ..config import settings as settings_mgr
from ..audit.center import v2 as center_mgr
from ..command import cmdmgr
from ..plugin import manager as plugin_mgr
from ..pipeline import pool
from ..pipeline import controller, stagemgr
from ..utils import version as version_mgr, proxy as proxy_mgr, announce as announce_mgr
from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller
from ..utils import logcache, ip
from . import taskmgr
from . import entities as core_entities
class Application:
"""运行时应用对象和上下文"""
event_loop: asyncio.AbstractEventLoop = None
# asyncio_tasks: list[asyncio.Task] = []
task_mgr: taskmgr.AsyncTaskManager = None
platform_mgr: im_mgr.PlatformManager = None
cmd_mgr: cmdmgr.CommandManager = None
@@ -36,6 +50,8 @@ class Application:
runner_mgr: runnermgr.RunnerManager = None
settings_mgr: settings_mgr.SettingsManager = None
# ======= 配置管理器 =======
command_cfg: config_mgr.ConfigManager = None
@@ -78,6 +94,12 @@ class Application:
logger: logging.Logger = None
persistence_mgr: persistencemgr.PersistenceManager = None
http_ctrl: http_controller.HTTPController = None
log_cache: logcache.LogCache = None
def __init__(self):
pass
@@ -85,34 +107,84 @@ class Application:
pass
async def run(self):
await self.plugin_mgr.initialize_plugins()
tasks = []
try:
tasks = [
asyncio.create_task(self.platform_mgr.run()),
asyncio.create_task(self.ctrl.run())
]
await self.plugin_mgr.initialize_plugins()
# 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
async def never_ending():
while True:
await asyncio.sleep(1)
# 挂信号处理
import signal
def signal_handler(sig, frame):
for task in tasks:
task.cancel()
self.logger.info("程序退出.")
exit(0)
signal.signal(signal.SIGINT, signal_handler)
await asyncio.gather(*tasks, return_exceptions=True)
self.task_mgr.create_task(self.platform_mgr.run(), name="platform-manager", scopes=[core_entities.LifecycleControlScope.APPLICATION, core_entities.LifecycleControlScope.PLATFORM])
self.task_mgr.create_task(self.ctrl.run(), name="query-controller", scopes=[core_entities.LifecycleControlScope.APPLICATION])
self.task_mgr.create_task(self.http_ctrl.run(), name="http-api-controller", scopes=[core_entities.LifecycleControlScope.APPLICATION])
self.task_mgr.create_task(never_ending(), name="never-ending-task", scopes=[core_entities.LifecycleControlScope.APPLICATION])
await self.print_web_access_info()
await self.task_mgr.wait_all()
except asyncio.CancelledError:
pass
except Exception as e:
self.logger.error(f"应用运行致命异常: {e}")
self.logger.debug(f"Traceback: {traceback.format_exc()}")
async def print_web_access_info(self):
"""打印访问 webui 的提示"""
import socket
host_ip = socket.gethostbyname(socket.gethostname())
public_ip = await ip.get_myip()
port = self.system_cfg.data['http-api']['port']
tips = f"""
=======================================
✨ 您可通过以下方式访问管理面板
🏠 本地地址http://{host_ip}:{port}/
🌐 公网地址http://{public_ip}:{port}/
📌 如果您在容器中运行此程序,请确保容器的 {port} 端口已对外暴露
🔗 若要使用公网地址访问,请阅读以下须知
1. 公网地址仅供参考,请以您的主机公网 IP 为准;
2. 要使用公网地址访问,请确保您的主机具有公网 IP并且系统防火墙已放行 {port} 端口;
🤯 WebUI 仍处于 Beta 测试阶段,如有问题或建议请反馈到 https://github.com/RockChinQ/LangBot/issues
=======================================
""".strip()
for line in tips.split("\n"):
self.logger.info(line)
async def reload(
self,
scope: core_entities.LifecycleControlScope,
):
match scope:
case core_entities.LifecycleControlScope.PLATFORM.value:
self.logger.info("执行热重载 scope="+scope)
await self.platform_mgr.shutdown()
self.platform_mgr = im_mgr.PlatformManager(self)
await self.platform_mgr.initialize()
self.task_mgr.create_task(self.platform_mgr.run(), name="platform-manager", scopes=[core_entities.LifecycleControlScope.APPLICATION, core_entities.LifecycleControlScope.PLATFORM])
case core_entities.LifecycleControlScope.PLUGIN.value:
self.logger.info("执行热重载 scope="+scope)
await self.plugin_mgr.destroy_plugins()
# 删除 sys.module 中所有的 plugins/* 下的模块
for mod in list(sys.modules.keys()):
if mod.startswith("plugins."):
del sys.modules[mod]
self.plugin_mgr = plugin_mgr.PluginManager(self)
await self.plugin_mgr.initialize()
await self.plugin_mgr.initialize_plugins()
await self.plugin_mgr.load_plugins()
await self.plugin_mgr.initialize_plugins()
case _:
pass

View File

@@ -1,10 +1,13 @@
from __future__ import print_function
import traceback
import asyncio
import os
from . import app
from ..audit import identifier
from . import stage
from ..utils import constants
# 引入启动阶段实现以便注册
from .stages import load_config, setup_logger, build_app, migrate, show_notes
@@ -19,13 +22,19 @@ stage_order = [
]
async def make_app() -> app.Application:
async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
# 生成标识符
identifier.init()
# 确定是否为调试模式
if "DEBUG" in os.environ and os.environ["DEBUG"] in ["true", "1"]:
constants.debug_mode = True
ap = app.Application()
ap.event_loop = loop
# 执行启动阶段
for stage_name in stage_order:
stage_cls = stage.preregistered_stages[stage_name]
@@ -38,9 +47,23 @@ async def make_app() -> app.Application:
return ap
async def main():
async def main(loop: asyncio.AbstractEventLoop):
try:
app_inst = await make_app()
# 挂系统信号处理
import signal
ap: app.Application
def signal_handler(sig, frame):
print("[Signal] 程序退出.")
# ap.shutdown()
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
app_inst = await make_app(loop)
ap = app_inst
await app_inst.run()
except Exception as e:
traceback.print_exc()

View File

@@ -6,7 +6,7 @@ required_deps = {
"anthropic": "anthropic",
"colorlog": "colorlog",
"aiocqhttp": "aiocqhttp",
"botpy": "qq-botpy",
"botpy": "qq-botpy-rc",
"PIL": "pillow",
"nakuru": "nakuru-project-idk",
"tiktoken": "tiktoken",
@@ -15,6 +15,12 @@ required_deps = {
"psutil": "psutil",
"async_lru": "async-lru",
"ollama": "ollama",
"quart": "quart",
"quart_cors": "quart-cors",
"sqlalchemy": "sqlalchemy[asyncio]",
"aiosqlite": "aiosqlite",
"aiofiles": "aiofiles",
"aioshutil": "aioshutil",
}

View File

@@ -5,6 +5,8 @@ import time
import colorlog
from ...utils import constants
log_colors_config = {
"DEBUG": "green", # cyan white
@@ -15,18 +17,18 @@ log_colors_config = {
}
async def init_logging() -> logging.Logger:
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
# 删除所有现有的logger
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
level = logging.INFO
if "DEBUG" in os.environ and os.environ["DEBUG"] in ["true", "1"]:
if constants.debug_mode:
level = logging.DEBUG
log_file_name = "data/logs/qcg-%s.log" % time.strftime(
"%Y-%m-%d-%H-%M-%S", time.localtime()
log_file_name = "data/logs/langbot-%s.log" % time.strftime(
"%Y-%m-%d", time.localtime()
)
qcg_logger = logging.getLogger("qcg")
@@ -34,14 +36,15 @@ async def init_logging() -> logging.Logger:
qcg_logger.setLevel(level)
color_formatter = colorlog.ColoredFormatter(
fmt="%(log_color)s[%(asctime)s.%(msecs)03d] %(pathname)s (%(lineno)d) - [%(levelname)s] :\n %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
fmt="%(log_color)s[%(asctime)s.%(msecs)03d] %(filename)s (%(lineno)d) - [%(levelname)s] : %(message)s",
datefmt="%m-%d %H:%M:%S",
log_colors=log_colors_config,
)
stream_handler = logging.StreamHandler(sys.stdout)
log_handlers: logging.Handler = [stream_handler, logging.FileHandler(log_file_name)]
log_handlers: list[logging.Handler] = [stream_handler, logging.FileHandler(log_file_name)]
log_handlers += extra_handlers if extra_handlers is not None else []
for handler in log_handlers:
handler.setLevel(level)

View File

@@ -17,6 +17,14 @@ from ..platform.types import events as platform_events
from ..platform.types import entities as platform_entities
class LifecycleControlScope(enum.Enum):
APPLICATION = "application"
PLATFORM = "platform"
PLUGIN = "plugin"
class LauncherTypes(enum.Enum):
"""一个请求的发起者类型"""

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("http-api-config", 13)
class HttpApiConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return 'http-api' not in self.ap.system_cfg.data or "persistence" not in self.ap.system_cfg.data
async def run(self):
"""执行迁移"""
self.ap.system_cfg.data['http-api'] = {
"enable": True,
"host": "0.0.0.0",
"port": 5300
}
self.ap.system_cfg.data['persistence'] = {
"sqlite": {
"path": "data/persistence.db"
},
"use": "sqlite"
}
await self.ap.system_cfg.dump_config()

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("force-delay-config", 14)
class ForceDelayConfigMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return type(self.ap.platform_cfg.data['force-delay']) == list
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['force-delay'] = {
"min": self.ap.platform_cfg.data['force-delay'][0],
"max": self.ap.platform_cfg.data['force-delay'][1]
}
await self.ap.platform_cfg.dump_config()

View File

@@ -15,6 +15,11 @@ from ...provider.sysprompt import sysprompt as llm_prompt_mgr
from ...provider.tools import toolmgr as llm_tool_mgr
from ...provider import runnermgr
from ...platform import manager as im_mgr
from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...utils import logcache
from .. import taskmgr
@stage.stage_class("BuildAppStage")
class BuildAppStage(stage.BootingStage):
@@ -24,6 +29,7 @@ class BuildAppStage(stage.BootingStage):
async def run(self, ap: app.Application):
"""构建app对象的各个组件对象并初始化
"""
ap.task_mgr = taskmgr.AsyncTaskManager(ap)
proxy_mgr = proxy.ProxyManager(ap)
await proxy_mgr.initialize()
@@ -58,6 +64,13 @@ class BuildAppStage(stage.BootingStage):
ap.query_pool = pool.QueryPool()
log_cache = logcache.LogCache()
ap.log_cache = log_cache
persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
await persistence_mgr_inst.initialize()
ap.persistence_mgr = persistence_mgr_inst
plugin_mgr_inst = plugin_mgr.PluginManager(ap)
await plugin_mgr_inst.initialize()
ap.plugin_mgr = plugin_mgr_inst
@@ -95,6 +108,9 @@ class BuildAppStage(stage.BootingStage):
await stage_mgr.initialize()
ap.stage_mgr = stage_mgr
http_ctrl = http_controller.HTTPController(ap)
await http_ctrl.initialize()
ap.http_ctrl = http_ctrl
ctrl = controller.Controller(ap)
ap.ctrl = ctrl

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from .. import stage, app
from ..bootutils import config
from ...config import settings as settings_mgr
from ...utils import schema
@stage.stage_class("LoadConfigStage")
@@ -12,12 +14,56 @@ class LoadConfigStage(stage.BootingStage):
async def run(self, ap: app.Application):
"""启动
"""
ap.settings_mgr = settings_mgr.SettingsManager(ap)
await ap.settings_mgr.initialize()
ap.command_cfg = await config.load_json_config("data/config/command.json", "templates/command.json", completion=False)
ap.pipeline_cfg = await config.load_json_config("data/config/pipeline.json", "templates/pipeline.json", completion=False)
ap.platform_cfg = await config.load_json_config("data/config/platform.json", "templates/platform.json", completion=False)
ap.provider_cfg = await config.load_json_config("data/config/provider.json", "templates/provider.json", completion=False)
ap.system_cfg = await config.load_json_config("data/config/system.json", "templates/system.json", completion=False)
ap.settings_mgr.register_manager(
name="command.json",
description="命令配置",
manager=ap.command_cfg,
schema=schema.CONFIG_COMMAND_SCHEMA,
doc_link="https://qchatgpt.rockchin.top/config/function/command.html"
)
ap.settings_mgr.register_manager(
name="pipeline.json",
description="消息处理流水线配置",
manager=ap.pipeline_cfg,
schema=schema.CONFIG_PIPELINE_SCHEMA,
doc_link="https://qchatgpt.rockchin.top/config/function/pipeline.html"
)
ap.settings_mgr.register_manager(
name="platform.json",
description="消息平台配置",
manager=ap.platform_cfg,
schema=schema.CONFIG_PLATFORM_SCHEMA,
doc_link="https://qchatgpt.rockchin.top/config/function/platform.html"
)
ap.settings_mgr.register_manager(
name="provider.json",
description="大模型能力配置",
manager=ap.provider_cfg,
schema=schema.CONFIG_PROVIDER_SCHEMA,
doc_link="https://qchatgpt.rockchin.top/config/function/provider.html"
)
ap.settings_mgr.register_manager(
name="system.json",
description="系统配置",
manager=ap.system_cfg,
schema=schema.CONFIG_SYSTEM_SCHEMA,
doc_link="https://qchatgpt.rockchin.top/config/function/system.html"
)
ap.plugin_setting_meta = await config.load_json_config("plugins/plugins.json", "templates/plugin-settings.json")
await ap.plugin_setting_meta.dump_config()

View File

@@ -6,7 +6,7 @@ from .. import stage, app
from .. import migration
from ..migrations import m001_sensitive_word_migration, m002_openai_config_migration, m003_anthropic_requester_cfg_completion, m004_moonshot_cfg_completion
from ..migrations import m005_deepseek_cfg_completion, m006_vision_config, m007_qcg_center_url, m008_ad_fixwin_config_migrate, m009_msg_truncator_cfg
from ..migrations import m010_ollama_requester_config, m011_command_prefix_config, m012_runner_config
from ..migrations import m010_ollama_requester_config, m011_command_prefix_config, m012_runner_config, m013_http_api_config, m014_force_delay_config
@stage.stage_class("MigrationStage")

View File

@@ -1,9 +1,38 @@
from __future__ import annotations
import logging
import asyncio
from datetime import datetime
from .. import stage, app
from ..bootutils import log
class PersistenceHandler(logging.Handler, object):
"""
保存日志到数据库
"""
ap: app.Application
def __init__(self, name, ap: app.Application):
logging.Handler.__init__(self)
self.ap = ap
def emit(self, record):
"""
emit函数为自定义handler类时必重写的函数这里可以根据需要对日志消息做一些处理比如发送日志到服务器
发出记录(Emit a record)
"""
try:
msg = self.format(record)
if self.ap.log_cache is not None:
self.ap.log_cache.add_log(msg)
except Exception:
self.handleError(record)
@stage.stage_class("SetupLoggerStage")
class SetupLoggerStage(stage.BootingStage):
"""设置日志器阶段
@@ -12,4 +41,9 @@ class SetupLoggerStage(stage.BootingStage):
async def run(self, ap: app.Application):
"""启动
"""
ap.logger = await log.init_logging()
persistence_handler = PersistenceHandler('LoggerHandler', ap)
extra_handlers = []
extra_handlers = [persistence_handler]
ap.logger = await log.init_logging(extra_handlers)

235
pkg/core/taskmgr.py Normal file
View File

@@ -0,0 +1,235 @@
from __future__ import annotations
import asyncio
import typing
import datetime
import traceback
from . import app
from . import entities as core_entities
class TaskContext:
"""任务跟踪上下文"""
current_action: str
"""当前正在执行的动作"""
log: str
"""记录日志"""
def __init__(self):
self.current_action = "default"
self.log = ""
def _log(self, msg: str):
self.log += msg + "\n"
def set_current_action(self, action: str):
self.current_action = action
def trace(
self,
msg: str,
action: str = None,
):
if action is not None:
self.set_current_action(action)
self._log(
f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {self.current_action} | {msg}"
)
def to_dict(self) -> dict:
return {"current_action": self.current_action, "log": self.log}
@staticmethod
def new() -> TaskContext:
return TaskContext()
@staticmethod
def placeholder() -> TaskContext:
global placeholder_context
if placeholder_context is None:
placeholder_context = TaskContext()
return placeholder_context
placeholder_context: TaskContext | None = None
class TaskWrapper:
"""任务包装器"""
_id_index: int = 0
"""任务ID索引"""
id: int
"""任务ID"""
task_type: str = "system" # 任务类型: system 或 user
"""任务类型"""
kind: str = "system_task" # 由发起者确定任务种类,通常同质化的任务种类相同
"""任务种类"""
name: str = ""
"""任务唯一名称"""
label: str = ""
"""任务显示名称"""
task_context: TaskContext
"""任务上下文"""
task: asyncio.Task
"""任务"""
task_stack: list = None
"""任务堆栈"""
ap: app.Application
"""应用实例"""
scopes: list[core_entities.LifecycleControlScope]
"""任务所属生命周期控制范围"""
def __init__(
self,
ap: app.Application,
coro: typing.Coroutine,
task_type: str = "system",
kind: str = "system_task",
name: str = "",
label: str = "",
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
):
self.id = TaskWrapper._id_index
TaskWrapper._id_index += 1
self.ap = ap
self.task_context = context or TaskContext()
self.task = self.ap.event_loop.create_task(coro)
self.task_type = task_type
self.kind = kind
self.name = name
self.label = label if label != "" else name
self.task.set_name(name)
self.scopes = scopes
def assume_exception(self):
try:
exception = self.task.exception()
if self.task_stack is None:
self.task_stack = self.task.get_stack()
return exception
except:
return None
def assume_result(self):
try:
return self.task.result()
except:
return None
def to_dict(self) -> dict:
exception_traceback = None
if self.assume_exception() is not None:
exception_traceback = 'Traceback (most recent call last):\n'
for frame in self.task_stack:
exception_traceback += f" File \"{frame.f_code.co_filename}\", line {frame.f_lineno}, in {frame.f_code.co_name}\n"
exception_traceback += f" {self.assume_exception().__str__()}\n"
return {
"id": self.id,
"task_type": self.task_type,
"kind": self.kind,
"name": self.name,
"label": self.label,
"scopes": [scope.value for scope in self.scopes],
"task_context": self.task_context.to_dict(),
"runtime": {
"done": self.task.done(),
"state": self.task._state,
"exception": self.assume_exception().__str__() if self.assume_exception() is not None else None,
"exception_traceback": exception_traceback,
"result": self.assume_result().__str__() if self.assume_result() is not None else None,
},
}
def cancel(self):
self.task.cancel()
class AsyncTaskManager:
"""保存app中的所有异步任务
包含系统级的和用户级(插件安装、更新等由用户直接发起的)的"""
ap: app.Application
tasks: list[TaskWrapper]
"""所有任务"""
def __init__(self, ap: app.Application):
self.ap = ap
self.tasks = []
def create_task(
self,
coro: typing.Coroutine,
task_type: str = "system",
kind: str = "system-task",
name: str = "",
label: str = "",
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
) -> TaskWrapper:
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
self.tasks.append(wrapper)
return wrapper
def create_user_task(
self,
coro: typing.Coroutine,
kind: str = "user-task",
name: str = "",
label: str = "",
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
) -> TaskWrapper:
return self.create_task(coro, "user", kind, name, label, context, scopes)
async def wait_all(self):
await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True)
def get_all_tasks(self) -> list[TaskWrapper]:
return self.tasks
def get_tasks_dict(
self,
type: str = None,
) -> dict:
return {
"tasks": [
t.to_dict() for t in self.tasks if type is None or t.task_type == type
],
"id_index": TaskWrapper._id_index,
}
def get_task_by_id(self, id: int) -> TaskWrapper | None:
for t in self.tasks:
if t.id == id:
return t
return None
def cancel_by_scope(self, scope: core_entities.LifecycleControlScope):
for wrapper in self.tasks:
if not wrapper.task.done() and scope in wrapper.scopes:
wrapper.task.cancel()

View File

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import abc
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
from ..core import app
preregistered_managers: list[type[BaseDatabaseManager]] = []
def manager_class(name: str) -> None:
"""注册一个数据库管理类"""
def decorator(cls: type[BaseDatabaseManager]) -> type[BaseDatabaseManager]:
cls.name = name
preregistered_managers.append(cls)
return cls
return decorator
class BaseDatabaseManager(abc.ABC):
"""基础数据库管理类"""
name: str
ap: app.Application
engine: sqlalchemy_asyncio.AsyncEngine
def __init__(self, ap: app.Application) -> None:
self.ap = ap
@abc.abstractmethod
async def initialize(self) -> None:
pass
def get_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
return self.engine

View File

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
from .. import database
@database.manager_class("sqlite")
class SQLiteDatabaseManager(database.BaseDatabaseManager):
"""SQLite 数据库管理类"""
async def initialize(self) -> None:
self.engine = sqlalchemy_asyncio.create_async_engine(f"sqlite+aiosqlite:///{self.ap.system_cfg.data['persistence']['sqlite']['path']}")

55
pkg/persistence/mgr.py Normal file
View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import asyncio
import datetime
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
from . import database
from ..core import app
from .databases import sqlite
class PersistenceManager:
"""持久化模块管理器"""
ap: app.Application
db: database.BaseDatabaseManager
"""数据库管理器"""
meta: sqlalchemy.MetaData
def __init__(self, ap: app.Application):
self.ap = ap
self.meta = sqlalchemy.MetaData()
async def initialize(self):
for manager in database.preregistered_managers:
self.db = manager(self.ap)
await self.db.initialize()
await self.create_tables()
async def create_tables(self):
# TODO: 对扩展友好
# 日志
async with self.get_db_engine().connect() as conn:
await conn.run_sync(self.meta.create_all)
await conn.commit()
async def execute_async(
self,
*args,
**kwargs
):
async with self.get_db_engine().connect() as conn:
await conn.execute(*args, **kwargs)
await conn.commit()
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
return self.db.get_engine()

View File

@@ -4,7 +4,6 @@ import asyncio
import typing
import traceback
from ..core import app, entities
from . import entities as pipeline_entities
from ..plugin import events
@@ -59,8 +58,13 @@ class Controller:
(await self.ap.sess_mgr.get_session(selected_query)).semaphore.release()
# 通知其他协程,有新的请求可以处理了
self.ap.query_pool.condition.notify_all()
asyncio.create_task(_process_query(selected_query))
self.ap.task_mgr.create_task(
_process_query(selected_query),
kind="query",
name=f"query-{selected_query.query_id}",
scopes=[entities.LifecycleControlScope.APPLICATION, entities.LifecycleControlScope.PLATFORM],
)
except Exception as e:
# traceback.print_exc()
self.ap.logger.error(f"控制器循环出错: {e}")
@@ -159,6 +163,23 @@ class Controller:
async def process_query(self, query: entities.Query):
"""处理请求
"""
# ======== 触发 MessageReceived 事件 ========
event_type = events.PersonMessageReceived if query.launcher_type == entities.LauncherTypes.PERSON else events.GroupMessageReceived
event_ctx = await self.ap.plugin_mgr.emit_event(
event=event_type(
launcher_type=query.launcher_type.value,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
message_chain=query.message_chain,
query=query
)
)
if event_ctx.is_prevented_default():
return
self.ap.logger.debug(f"Processing query {query}")
try:
@@ -166,7 +187,6 @@ class Controller:
except Exception as e:
self.ap.logger.error(f"处理请求时出错 query_id={query.query_id} stage={query.current_stage.inst_name} : {e}")
self.ap.logger.debug(f"Traceback: {traceback.format_exc()}")
# traceback.print_exc()
finally:
self.ap.logger.debug(f"Query {query} processed")

View File

@@ -19,7 +19,10 @@ class SendResponseBackStage(stage.PipelineStage):
async def process(self, query: core_entities.Query, stage_inst_name: str) -> entities.StageProcessResult:
"""处理
"""
random_delay = random.uniform(*self.ap.platform_cfg.data['force-delay'])
random_range = (self.ap.platform_cfg.data['force-delay']['min'], self.ap.platform_cfg.data['force-delay']['max'])
random_delay = random.uniform(*random_range)
self.ap.logger.debug(
"根据规则强制延迟回复: %s s",

View File

@@ -37,76 +37,40 @@ class PlatformManager:
async def initialize(self):
from .sources import yirimirai, nakuru, aiocqhttp, qqbotpy
from .sources import nakuru, aiocqhttp, qqbotpy
async def on_friend_message(event: platform_events.FriendMessage, adapter: msadapter.MessageSourceAdapter):
event_ctx = await self.ap.plugin_mgr.emit_event(
event=events.PersonMessageReceived(
launcher_type='person',
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_chain=event.message_chain,
query=None
)
await self.ap.query_pool.add_query(
launcher_type=core_entities.LauncherTypes.PERSON,
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter
)
if not event_ctx.is_prevented_default():
await self.ap.query_pool.add_query(
launcher_type=core_entities.LauncherTypes.PERSON,
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter
)
async def on_stranger_message(event: platform_events.StrangerMessage, adapter: msadapter.MessageSourceAdapter):
event_ctx = await self.ap.plugin_mgr.emit_event(
event=events.PersonMessageReceived(
launcher_type='person',
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_chain=event.message_chain,
query=None
)
await self.ap.query_pool.add_query(
launcher_type=core_entities.LauncherTypes.PERSON,
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter
)
if not event_ctx.is_prevented_default():
await self.ap.query_pool.add_query(
launcher_type=core_entities.LauncherTypes.PERSON,
launcher_id=event.sender.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter
)
async def on_group_message(event: platform_events.GroupMessage, adapter: msadapter.MessageSourceAdapter):
event_ctx = await self.ap.plugin_mgr.emit_event(
event=events.GroupMessageReceived(
launcher_type='group',
launcher_id=event.group.id,
sender_id=event.sender.id,
message_chain=event.message_chain,
query=None
)
await self.ap.query_pool.add_query(
launcher_type=core_entities.LauncherTypes.GROUP,
launcher_id=event.group.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter
)
if not event_ctx.is_prevented_default():
await self.ap.query_pool.add_query(
launcher_type=core_entities.LauncherTypes.GROUP,
launcher_id=event.group.id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
adapter=adapter
)
index = 0
@@ -174,19 +138,30 @@ class PlatformManager:
try:
tasks = []
for adapter in self.adapters:
async def exception_wrapper(adapter):
async def exception_wrapper(adapter: msadapter.MessageSourceAdapter):
try:
await adapter.run_async()
except Exception as e:
if isinstance(e, asyncio.CancelledError):
return
self.ap.logger.error('平台适配器运行出错: ' + str(e))
self.ap.logger.debug(f"Traceback: {traceback.format_exc()}")
tasks.append(exception_wrapper(adapter))
for task in tasks:
asyncio.create_task(task)
self.ap.task_mgr.create_task(
task,
kind="platform-adapter",
name=f"platform-adapter-{adapter.name}",
scopes=[core_entities.LifecycleControlScope.APPLICATION, core_entities.LifecycleControlScope.PLATFORM],
)
except Exception as e:
self.ap.logger.error('平台适配器运行出错: ' + str(e))
self.ap.logger.debug(f"Traceback: {traceback.format_exc()}")
async def shutdown(self):
for adapter in self.adapters:
await adapter.kill()
self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)

View File

@@ -328,5 +328,5 @@ class NakuruProjectAdapter(adapter_model.MessageSourceAdapter):
while True:
await asyncio.sleep(1)
def kill(self) -> bool:
async def kill(self) -> bool:
return False

View File

@@ -21,7 +21,6 @@ from ...platform.types import events as platform_events
from ...platform.types import message as platform_message
class OfficialGroupMessage(platform_events.GroupMessage):
pass
@@ -588,8 +587,12 @@ class OfficialAdapter(adapter_model.MessageSourceAdapter):
self.member_openid_mapping, self.group_openid_mapping
)
self.ap.logger.info("运行 QQ 官方适配器")
await self.bot.start(**self.cfg)
self.cfg['ret_coro'] = True
def kill(self) -> bool:
return False
self.ap.logger.info("运行 QQ 官方适配器")
await (await self.bot.start(**self.cfg))
async def kill(self) -> bool:
if not self.bot.is_closed():
await self.bot.close()
return True

View File

@@ -1,121 +0,0 @@
# import asyncio
# import typing
# from .. import adapter as adapter_model
# from ...core import app
# @adapter_model.adapter_class("yiri-mirai")
# class YiriMiraiAdapter(adapter_model.MessageSourceAdapter):
# """YiriMirai适配器"""
# bot: mirai.Mirai
# def __init__(self, config: dict, ap: app.Application):
# """初始化YiriMirai的对象"""
# self.ap = ap
# self.config = config
# if 'adapter' not in config or \
# config['adapter'] == 'WebSocketAdapter':
# self.bot = mirai.Mirai(
# qq=config['qq'],
# adapter=mirai.WebSocketAdapter(
# host=config['host'],
# port=config['port'],
# verify_key=config['verifyKey']
# )
# )
# elif config['adapter'] == 'HTTPAdapter':
# self.bot = mirai.Mirai(
# qq=config['qq'],
# adapter=mirai.HTTPAdapter(
# host=config['host'],
# port=config['port'],
# verify_key=config['verifyKey']
# )
# )
# else:
# raise Exception('Unknown adapter for YiriMirai: ' + config['adapter'])
# async def send_message(
# self,
# target_type: str,
# target_id: str,
# message: mirai.MessageChain
# ):
# """发送消息
# Args:
# target_type (str): 目标类型,`person`或`group`
# target_id (str): 目标ID
# message (mirai.MessageChain): YiriMirai库的消息链
# """
# task = None
# if target_type == 'person':
# task = self.bot.send_friend_message(int(target_id), message)
# elif target_type == 'group':
# task = self.bot.send_group_message(int(target_id), message)
# else:
# raise Exception('Unknown target type: ' + target_type)
# await task
# async def reply_message(
# self,
# message_source: mirai.MessageEvent,
# message: mirai.MessageChain,
# quote_origin: bool = False
# ):
# """回复消息
# Args:
# message_source (mirai.MessageEvent): YiriMirai消息源事件
# message (mirai.MessageChain): YiriMirai库的消息链
# quote_origin (bool, optional): 是否引用原消息. Defaults to False.
# """
# await self.bot.send(message_source, message, quote_origin)
# async def is_muted(self, group_id: int) -> bool:
# result = await self.bot.member_info(target=group_id, member_id=self.bot.qq).get()
# if result.mute_time_remaining > 0:
# return True
# return False
# def register_listener(
# self,
# event_type: typing.Type[mirai.Event],
# callback: typing.Callable[[mirai.Event, adapter_model.MessageSourceAdapter], None]
# ):
# """注册事件监听器
# Args:
# event_type (typing.Type[mirai.Event]): YiriMirai事件类型
# callback (typing.Callable[[mirai.Event], None]): 回调函数接收一个参数为YiriMirai事件
# """
# async def wrapper(event: mirai.Event):
# await callback(event, self)
# self.bot.on(event_type)(wrapper)
# def unregister_listener(
# self,
# event_type: typing.Type[mirai.Event],
# callback: typing.Callable[[mirai.Event, adapter_model.MessageSourceAdapter], None]
# ):
# """注销事件监听器
# Args:
# event_type (typing.Type[mirai.Event]): YiriMirai事件类型
# callback (typing.Callable[[mirai.Event], None]): 回调函数接收一个参数为YiriMirai事件
# """
# assert isinstance(self.bot, mirai.Mirai)
# bus = self.bot.bus
# assert isinstance(bus, mirai.models.bus.ModelEventBus)
# bus.unsubscribe(event_type, callback)
# async def run_async(self):
# self.bot_account_id = self.bot.qq
# return await MiraiRunner(self.bot)._run()
# async def kill(self) -> bool:
# return False

View File

@@ -545,7 +545,7 @@ class Image(MessageComponent):
@pydantic.validator('path')
def validate_path(cls, path: typing.Union[str, Path, None]):
"""修复 path 参数的行为,使之相对于 QChatGPT 的启动路径。"""
"""修复 path 参数的行为,使之相对于 LangBot 的启动路径。"""
if path:
try:
return str(Path(path).resolve(strict=True))
@@ -673,7 +673,7 @@ class Voice(MessageComponent):
"""语音的长度,单位为秒。"""
@pydantic.validator('path')
def validate_path(cls, path: typing.Optional[str]):
"""修复 path 参数的行为,使之相对于 QChatGPT 的启动路径。"""
"""修复 path 参数的行为,使之相对于 LangBot 的启动路径。"""
if path:
try:
return str(Path(path).resolve(strict=True))

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import typing
import abc
import pydantic
import enum
from . import events
from ..provider.tools import entities as tools_entities
@@ -85,15 +86,24 @@ class BasePlugin(metaclass=abc.ABCMeta):
"""应用程序对象"""
def __init__(self, host: APIHost):
"""初始化阶段被调用"""
self.host = host
async def initialize(self):
"""初始化插件"""
"""初始化阶段被调用"""
pass
async def destroy(self):
"""释放/禁用插件时被调用"""
pass
def __del__(self):
"""释放/禁用插件时被调用"""
pass
class APIHost:
"""QChatGPT API 宿主"""
"""LangBot API 宿主"""
ap: app.Application
@@ -126,7 +136,7 @@ class APIHost:
if self.ap.ver_mgr.compare_version_str(qchatgpt_version, ge) < 0 or \
(self.ap.ver_mgr.compare_version_str(qchatgpt_version, le) > 0):
raise Exception("QChatGPT 版本不满足要求,某些功能(可能是由插件提供的)无法正常使用。(要求版本:{}-{},但当前版本:{}".format(ge, le, qchatgpt_version))
raise Exception("LangBot 版本不满足要求,某些功能(可能是由插件提供的)无法正常使用。(要求版本:{}-{},但当前版本:{}".format(ge, le, qchatgpt_version))
return True
@@ -247,6 +257,16 @@ class EventContext:
EventContext.eid += 1
class RuntimeContainerStatus(enum.Enum):
"""插件容器状态"""
MOUNTED = "mounted"
"""已加载进内存,所有位于运行时记录中的 RuntimeContainer 至少是这个状态"""
INITIALIZED = "initialized"
"""已初始化"""
class RuntimeContainer(pydantic.BaseModel):
"""运行时的插件容器
@@ -294,6 +314,9 @@ class RuntimeContainer(pydantic.BaseModel):
content_functions: list[tools_entities.LLMFunction] = []
"""内容函数"""
status: RuntimeContainerStatus = RuntimeContainerStatus.MOUNTED
"""插件状态"""
class Config:
arbitrary_types_allowed = True
@@ -318,5 +341,30 @@ class RuntimeContainer(pydantic.BaseModel):
self.priority = setting['priority']
self.enabled = setting['enabled']
for function in self.content_functions:
function.enable = self.enabled
def model_dump(self, *args, **kwargs):
return {
'name': self.plugin_name,
'description': self.plugin_description,
'version': self.plugin_version,
'author': self.plugin_author,
'source': self.plugin_source,
'main_file': self.main_file,
'pkg_path': self.pkg_path,
'enabled': self.enabled,
'priority': self.priority,
'event_handlers': {
event_name.__name__: handler.__name__
for event_name, handler in self.event_handlers.items()
},
'content_functions': [
{
'name': function.name,
'human_desc': function.human_desc,
'description': function.description,
'parameters': function.parameters,
'func': function.func.__name__,
}
for function in self.content_functions
],
'status': self.status.value,
}

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import typing
import abc
from ..core import app
from ..core import app, taskmgr
class PluginInstaller(metaclass=abc.ABCMeta):
@@ -21,6 +21,7 @@ class PluginInstaller(metaclass=abc.ABCMeta):
async def install_plugin(
self,
plugin_source: str,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""安装插件
"""
@@ -30,6 +31,7 @@ class PluginInstaller(metaclass=abc.ABCMeta):
async def uninstall_plugin(
self,
plugin_name: str,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""卸载插件
"""
@@ -40,6 +42,7 @@ class PluginInstaller(metaclass=abc.ABCMeta):
self,
plugin_name: str,
plugin_source: str=None,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""更新插件
"""

View File

@@ -5,10 +5,14 @@ import os
import shutil
import zipfile
import requests
import aiohttp
import aiofiles
import aiofiles.os as aiofiles_os
import aioshutil
from .. import installer, errors
from ...utils import pkgmgr
from ...core import taskmgr
class GitHubRepoInstaller(installer.PluginInstaller):
@@ -28,65 +32,65 @@ class GitHubRepoInstaller(installer.PluginInstaller):
return repo[0].split("/")
else:
return None
async def download_plugin_source_code(self, repo_url: str, target_path: str, task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder()) -> str:
"""下载插件源码(全异步)"""
async def download_plugin_source_code(self, repo_url: str, target_path: str) -> str:
"""下载插件源码"""
# 检查源类型
# 提取 username/repo , 正则表达式
repo = self.get_github_plugin_repo_label(repo_url)
target_path += repo[1]
if repo is not None: # github
self.ap.logger.debug("正在下载源码...")
zipball_url = f"https://api.github.com/repos/{'/'.join(repo)}/zipball/HEAD"
zip_resp = requests.get(
url=zipball_url, proxies=self.ap.proxy_mgr.get_forward_proxies(), stream=True
)
if zip_resp.status_code != 200:
raise Exception("下载源码失败: {}".format(zip_resp.text))
if os.path.exists("temp/" + target_path):
shutil.rmtree("temp/" + target_path)
if os.path.exists(target_path):
shutil.rmtree(target_path)
os.makedirs("temp/" + target_path)
with open("temp/" + target_path + "/source.zip", "wb") as f:
for chunk in zip_resp.iter_content(chunk_size=1024):
if chunk:
f.write(chunk)
self.ap.logger.debug("解压中...")
with zipfile.ZipFile("temp/" + target_path + "/source.zip", "r") as zip_ref:
zip_ref.extractall("temp/" + target_path)
os.remove("temp/" + target_path + "/source.zip")
# 目标是 username-repo-hash , 用正则表达式提取完整的文件夹名,复制到 plugins/repo
import glob
# 获取解压后的文件夹名
unzip_dir = glob.glob("temp/" + target_path + "/*")[0]
# 复制到 plugins/repo
shutil.copytree(unzip_dir, target_path + "/")
# 删除解压后的文件夹
shutil.rmtree(unzip_dir)
self.ap.logger.debug("源码下载完成。")
else:
if repo is None:
raise errors.PluginInstallerError('仅支持GitHub仓库地址')
self.ap.logger.debug("正在下载源码...")
task_context.trace("下载源码...", "download-plugin-source-code")
zipball_url = f"https://api.github.com/repos/{'/'.join(repo)}/zipball/HEAD"
zip_resp: bytes = None
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
url=zipball_url,
timeout=aiohttp.ClientTimeout(total=300)
) as resp:
if resp.status != 200:
raise errors.PluginInstallerError(f"下载源码失败: {resp.text}")
zip_resp = await resp.read()
if await aiofiles_os.path.exists("temp/" + target_path):
await aioshutil.rmtree("temp/" + target_path)
if await aiofiles_os.path.exists(target_path):
await aioshutil.rmtree(target_path)
await aiofiles_os.makedirs("temp/" + target_path)
async with aiofiles.open("temp/" + target_path + "/source.zip", "wb") as f:
await f.write(zip_resp)
self.ap.logger.debug("解压中...")
task_context.trace("解压中...", "unzip-plugin-source-code")
with zipfile.ZipFile("temp/" + target_path + "/source.zip", "r") as zip_ref:
zip_ref.extractall("temp/" + target_path)
await aiofiles_os.remove("temp/" + target_path + "/source.zip")
import glob
unzip_dir = glob.glob("temp/" + target_path + "/*")[0]
await aioshutil.copytree(unzip_dir, target_path + "/")
await aioshutil.rmtree(unzip_dir)
self.ap.logger.debug("源码下载完成。")
return repo[1]
async def install_requirements(self, path: str):
if os.path.exists(path + "/requirements.txt"):
pkgmgr.install_requirements(path + "/requirements.txt")
@@ -94,13 +98,20 @@ class GitHubRepoInstaller(installer.PluginInstaller):
async def install_plugin(
self,
plugin_source: str,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""安装插件
"""
repo_label = await self.download_plugin_source_code(plugin_source, "plugins/")
task_context.trace("下载插件源码...", "install-plugin")
repo_label = await self.download_plugin_source_code(plugin_source, "plugins/", task_context)
task_context.trace("安装插件依赖...", "install-plugin")
await self.install_requirements("plugins/" + repo_label)
task_context.trace("完成.", "install-plugin")
await self.ap.plugin_mgr.setting.record_installed_plugin_source(
"plugins/"+repo_label+'/', plugin_source
)
@@ -108,6 +119,7 @@ class GitHubRepoInstaller(installer.PluginInstaller):
async def uninstall_plugin(
self,
plugin_name: str,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""卸载插件
"""
@@ -116,15 +128,20 @@ class GitHubRepoInstaller(installer.PluginInstaller):
if plugin_container is None:
raise errors.PluginInstallerError('插件不存在或未成功加载')
else:
shutil.rmtree(plugin_container.pkg_path)
task_context.trace("删除插件目录...", "uninstall-plugin")
await aioshutil.rmtree(plugin_container.pkg_path)
task_context.trace("完成, 重新加载以生效.", "uninstall-plugin")
async def update_plugin(
self,
plugin_name: str,
plugin_source: str=None,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""更新插件
"""
task_context.trace("更新插件...", "update-plugin")
plugin_container = self.ap.plugin_mgr.get_plugin_by_name(plugin_name)
if plugin_container is None:
@@ -133,7 +150,9 @@ class GitHubRepoInstaller(installer.PluginInstaller):
if plugin_container.plugin_source:
plugin_source = plugin_container.plugin_source
await self.install_plugin(plugin_source)
task_context.trace("转交安装任务.", "update-plugin")
await self.install_plugin(plugin_source, task_context)
else:
raise errors.PluginInstallerError('插件无源码信息,无法更新')

View File

@@ -13,13 +13,16 @@ class PluginLoader(metaclass=abc.ABCMeta):
ap: app.Application
plugins: list[context.RuntimeContainer]
def __init__(self, ap: app.Application):
self.ap = ap
self.plugins = []
async def initialize(self):
pass
@abc.abstractmethod
async def load_plugins(self) -> list[context.RuntimeContainer]:
async def load_plugins(self):
pass

View File

@@ -5,7 +5,7 @@ import pkgutil
import importlib
import traceback
from .. import loader, events, context, models, host
from .. import loader, events, context, models
from ...core import entities as core_entities
from ...provider.tools import entities as tools_entities
from ...utils import funcschema
@@ -20,7 +20,14 @@ class PluginLoader(loader.PluginLoader):
_current_container: context.RuntimeContainer = None
containers: list[context.RuntimeContainer] = []
plugins: list[context.RuntimeContainer] = []
def __init__(self, ap):
self.ap = ap
self.plugins = []
self._current_pkg_path = ''
self._current_module_path = ''
self._current_container = None
async def initialize(self):
"""初始化"""
@@ -77,8 +84,10 @@ class PluginLoader(loader.PluginLoader):
}
# 把 ctx.event 所有的属性都放到 args 里
for k, v in ctx.event.dict().items():
args[k] = v
# for k, v in ctx.event.dict().items():
# args[k] = v
for attr_name in ctx.event.__dict__.keys():
args[attr_name] = getattr(ctx.event, attr_name)
func(plugin, **args)
@@ -113,7 +122,6 @@ class PluginLoader(loader.PluginLoader):
name=function_name,
human_desc='',
description=function_schema['description'],
enable=True,
parameters=function_schema['parameters'],
func=handler,
)
@@ -153,7 +161,6 @@ class PluginLoader(loader.PluginLoader):
name=function_name,
human_desc='',
description=function_schema['description'],
enable=True,
parameters=function_schema['parameters'],
func=func,
)
@@ -189,15 +196,13 @@ class PluginLoader(loader.PluginLoader):
importlib.import_module(module.__name__ + "." + item.name)
if self._current_container is not None:
self.containers.append(self._current_container)
self.plugins.append(self._current_container)
self.ap.logger.debug(f'插件 {self._current_container} 已加载')
except:
self.ap.logger.error(f'加载插件模块 {prefix + item.name} 时发生错误')
traceback.print_exc()
async def load_plugins(self) -> list[context.RuntimeContainer]:
async def load_plugins(self):
"""加载插件
"""
await self._walk_plugin_path(__import__("plugins", fromlist=[""]))
return self.containers

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import typing
import traceback
from ..core import app
from ..core import app, taskmgr
from . import context, loader, events, installer, setting, models
from .loaders import classic
from .installers import github
@@ -22,7 +22,22 @@ class PluginManager:
api_host: context.APIHost
plugins: list[context.RuntimeContainer]
def plugins(
self,
enabled: bool=None,
status: context.RuntimeContainerStatus=None,
) -> list[context.RuntimeContainer]:
"""获取插件列表
"""
plugins = self.loader.plugins
if enabled is not None:
plugins = [plugin for plugin in plugins if plugin.enabled == enabled]
if status is not None:
plugins = [plugin for plugin in plugins if plugin.status == status]
return plugins
def __init__(self, ap: app.Application):
self.ap = ap
@@ -30,7 +45,6 @@ class PluginManager:
self.installer = github.GitHubRepoInstaller(ap)
self.setting = setting.SettingManager(ap)
self.api_host = context.APIHost(ap)
self.plugins = []
async def initialize(self):
await self.loader.initialize()
@@ -41,34 +55,66 @@ class PluginManager:
setattr(models, 'require_ver', self.api_host.require_ver)
async def load_plugins(self):
self.plugins = await self.loader.load_plugins()
await self.loader.load_plugins()
await self.setting.sync_setting(self.plugins)
await self.setting.sync_setting(self.loader.plugins)
# 按优先级倒序
self.plugins.sort(key=lambda x: x.priority, reverse=True)
self.loader.plugins.sort(key=lambda x: x.priority, reverse=True)
self.ap.logger.debug(f'优先级排序后的插件列表 {self.plugins}')
self.ap.logger.debug(f'优先级排序后的插件列表 {self.loader.plugins}')
async def initialize_plugin(self, plugin: context.RuntimeContainer):
self.ap.logger.debug(f'初始化插件 {plugin.plugin_name}')
plugin.plugin_inst = plugin.plugin_class(self.api_host)
plugin.plugin_inst.ap = self.ap
plugin.plugin_inst.host = self.api_host
await plugin.plugin_inst.initialize()
plugin.status = context.RuntimeContainerStatus.INITIALIZED
async def initialize_plugins(self):
for plugin in self.plugins:
for plugin in self.plugins():
if not plugin.enabled:
self.ap.logger.debug(f'插件 {plugin.plugin_name} 未启用,跳过初始化')
continue
try:
plugin.plugin_inst = plugin.plugin_class(self.api_host)
plugin.plugin_inst.ap = self.ap
plugin.plugin_inst.host = self.api_host
await plugin.plugin_inst.initialize()
await self.initialize_plugin(plugin)
except Exception as e:
self.ap.logger.error(f'插件 {plugin.plugin_name} 初始化失败: {e}')
self.ap.logger.exception(e)
continue
async def destroy_plugin(self, plugin: context.RuntimeContainer):
if plugin.status != context.RuntimeContainerStatus.INITIALIZED:
return
self.ap.logger.debug(f'释放插件 {plugin.plugin_name}')
plugin.plugin_inst.__del__()
await plugin.plugin_inst.destroy()
plugin.plugin_inst = None
plugin.status = context.RuntimeContainerStatus.MOUNTED
async def destroy_plugins(self):
for plugin in self.plugins():
if plugin.status != context.RuntimeContainerStatus.INITIALIZED:
self.ap.logger.debug(f'插件 {plugin.plugin_name} 未初始化,跳过释放')
continue
try:
await self.destroy_plugin(plugin)
except Exception as e:
self.ap.logger.error(f'插件 {plugin.plugin_name} 释放失败: {e}')
self.ap.logger.exception(e)
continue
async def install_plugin(
self,
plugin_source: str,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""安装插件
"""
await self.installer.install_plugin(plugin_source)
await self.installer.install_plugin(plugin_source, task_context)
await self.ap.ctr_mgr.plugin.post_install_record(
{
@@ -79,16 +125,25 @@ class PluginManager:
}
)
task_context.trace('重载插件..', 'reload-plugin')
await self.ap.reload(scope='plugin')
async def uninstall_plugin(
self,
plugin_name: str,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""卸载插件
"""
await self.installer.uninstall_plugin(plugin_name)
plugin_container = self.get_plugin_by_name(plugin_name)
if plugin_container is None:
raise ValueError(f'插件 {plugin_name} 不存在')
await self.destroy_plugin(plugin_container)
await self.installer.uninstall_plugin(plugin_name, task_context)
await self.ap.ctr_mgr.plugin.post_remove_record(
{
"name": plugin_name,
@@ -98,14 +153,18 @@ class PluginManager:
}
)
task_context.trace('重载插件..', 'reload-plugin')
await self.ap.reload(scope='plugin')
async def update_plugin(
self,
plugin_name: str,
plugin_source: str=None,
task_context: taskmgr.TaskContext = taskmgr.TaskContext.placeholder(),
):
"""更新插件
"""
await self.installer.update_plugin(plugin_name, plugin_source)
await self.installer.update_plugin(plugin_name, plugin_source, task_context)
plugin_container = self.get_plugin_by_name(plugin_name)
@@ -120,10 +179,13 @@ class PluginManager:
new_version="HEAD"
)
task_context.trace('重载插件..', 'reload-plugin')
await self.ap.reload(scope='plugin')
def get_plugin_by_name(self, plugin_name: str) -> context.RuntimeContainer:
"""通过插件名获取插件
"""
for plugin in self.plugins:
for plugin in self.plugins():
if plugin.plugin_name == plugin_name:
return plugin
return None
@@ -139,30 +201,32 @@ class PluginManager:
emitted_plugins: list[context.RuntimeContainer] = []
for plugin in self.plugins:
if plugin.enabled:
if event.__class__ in plugin.event_handlers:
self.ap.logger.debug(f'插件 {plugin.plugin_name} 处理事件 {event.__class__.__name__}')
is_prevented_default_before_call = ctx.is_prevented_default()
for plugin in self.plugins(
enabled=True,
status=context.RuntimeContainerStatus.INITIALIZED
):
if event.__class__ in plugin.event_handlers:
self.ap.logger.debug(f'插件 {plugin.plugin_name} 处理事件 {event.__class__.__name__}')
is_prevented_default_before_call = ctx.is_prevented_default()
try:
await plugin.event_handlers[event.__class__](
plugin.plugin_inst,
ctx
)
except Exception as e:
self.ap.logger.error(f'插件 {plugin.plugin_name} 处理事件 {event.__class__.__name__} 时发生错误: {e}')
self.ap.logger.debug(f"Traceback: {traceback.format_exc()}")
emitted_plugins.append(plugin)
try:
await plugin.event_handlers[event.__class__](
plugin.plugin_inst,
ctx
)
except Exception as e:
self.ap.logger.error(f'插件 {plugin.plugin_name} 处理事件 {event.__class__.__name__} 时发生错误: {e}')
self.ap.logger.debug(f"Traceback: {traceback.format_exc()}")
emitted_plugins.append(plugin)
if not is_prevented_default_before_call and ctx.is_prevented_default():
self.ap.logger.debug(f'插件 {plugin.plugin_name} 阻止了默认行为执行')
if not is_prevented_default_before_call and ctx.is_prevented_default():
self.ap.logger.debug(f'插件 {plugin.plugin_name} 阻止了默认行为执行')
if ctx.is_prevented_postorder():
self.ap.logger.debug(f'插件 {plugin.plugin_name} 阻止了后序插件的执行')
break
if ctx.is_prevented_postorder():
self.ap.logger.debug(f'插件 {plugin.plugin_name} 阻止了后序插件的执行')
break
for key in ctx.__return_value__.keys():
if hasattr(ctx.event, key):
@@ -186,3 +250,41 @@ class PluginManager:
)
return ctx
async def update_plugin_switch(self, plugin_name: str, new_status: bool):
if self.get_plugin_by_name(plugin_name) is not None:
for plugin in self.plugins():
if plugin.plugin_name == plugin_name:
if plugin.enabled == new_status:
return False
# 初始化/释放插件
if new_status:
await self.initialize_plugin(plugin)
else:
await self.destroy_plugin(plugin)
plugin.enabled = new_status
await self.setting.dump_container_setting(self.loader.plugins)
break
return True
else:
return False
async def reorder_plugins(self, plugins: list[dict]):
for plugin in plugins:
plugin_name = plugin.get('name')
plugin_priority = plugin.get('priority')
for plugin in self.loader.plugins:
if plugin.plugin_name == plugin_name:
plugin.priority = plugin_priority
break
self.loader.plugins.sort(key=lambda x: x.priority, reverse=True)
await self.setting.dump_container_setting(self.loader.plugins)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from ...core import app, entities as core_entities
from ...plugin import context as plugin_context
class SessionManager:
@@ -51,7 +52,10 @@ class SessionManager:
prompt=await self.ap.prompt_mgr.get_prompt(session.use_prompt_name),
messages=[],
use_model=await self.ap.model_mgr.get_model_by_name(self.ap.provider_cfg.data['model']),
use_funcs=await self.ap.tool_mgr.get_all_functions(),
use_funcs=await self.ap.tool_mgr.get_all_functions(
plugin_enabled=True,
plugin_status=plugin_context.RuntimeContainerStatus.INITIALIZED,
),
)
session.conversations.append(conversation)
session.using_conversation = conversation

View File

@@ -20,8 +20,6 @@ class LLMFunction(pydantic.BaseModel):
description: str
"""给LLM识别的函数描述"""
enable: typing.Optional[bool] = True
parameters: dict
func: typing.Callable

View File

@@ -20,28 +20,25 @@ class ToolManager:
async def initialize(self):
pass
async def get_function(self, name: str) -> entities.LLMFunction:
"""获取函数"""
for function in await self.get_all_functions():
if function.name == name:
return function
return None
async def get_function_and_plugin(
self, name: str
) -> typing.Tuple[entities.LLMFunction, plugin_context.BasePlugin]:
"""获取函数和插件"""
for plugin in self.ap.plugin_mgr.plugins:
"""获取函数和插件实例"""
for plugin in self.ap.plugin_mgr.plugins(
enabled=True, status=plugin_context.RuntimeContainerStatus.INITIALIZED
):
for function in plugin.content_functions:
if function.name == name:
return function, plugin.plugin_inst
return None, None
async def get_all_functions(self) -> list[entities.LLMFunction]:
async def get_all_functions(self, plugin_enabled: bool=None, plugin_status: plugin_context.RuntimeContainerStatus=None) -> list[entities.LLMFunction]:
"""获取所有函数"""
all_functions: list[entities.LLMFunction] = []
for plugin in self.ap.plugin_mgr.plugins:
for plugin in self.ap.plugin_mgr.plugins(
enabled=plugin_enabled, status=plugin_status
):
all_functions.extend(plugin.content_functions)
return all_functions
@@ -51,16 +48,15 @@ class ToolManager:
tools = []
for function in use_funcs:
if function.enable:
function_schema = {
"type": "function",
"function": {
"name": function.name,
"description": function.description,
"parameters": function.parameters,
},
}
tools.append(function_schema)
function_schema = {
"type": "function",
"function": {
"name": function.name,
"description": function.description,
"parameters": function.parameters,
},
}
tools.append(function_schema)
return tools
@@ -92,13 +88,12 @@ class ToolManager:
tools = []
for function in use_funcs:
if function.enable:
function_schema = {
"name": function.name,
"description": function.description,
"input_schema": function.parameters,
}
tools.append(function_schema)
function_schema = {
"name": function.name,
"description": function.description,
"input_schema": function.parameters,
}
tools.append(function_schema)
return tools

View File

@@ -48,7 +48,7 @@ class AnnouncementManager:
) -> list[Announcement]:
"""获取所有公告"""
resp = requests.get(
url="https://api.github.com/repos/RockChinQ/QChatGPT/contents/res/announcement.json",
url="https://api.github.com/repos/RockChinQ/LangBot/contents/res/announcement.json",
proxies=self.ap.proxy_mgr.get_forward_proxies(),
timeout=5
)

View File

@@ -1 +1,3 @@
semantic_version = "v3.3.1.1"
debug_mode = False

9
pkg/utils/ip.py Normal file
View File

@@ -0,0 +1,9 @@
import aiohttp
async def get_myip() -> str:
try:
async with aiohttp.ClientSession() as session:
async with session.get("https://ip.useragentinfo.com/myip") as response:
return await response.text()
except Exception as e:
return '0.0.0.0'

64
pkg/utils/logcache.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import pydantic
LOG_PAGE_SIZE = 20
MAX_CACHED_PAGES = 10
class LogPage():
"""日志页"""
number: int
"""页码"""
logs: list[str]
def __init__(self, number: int):
self.number = number
self.logs = []
def add_log(self, log: str) -> bool:
"""添加日志
Returns:
bool: 是否已满
"""
self.logs.append(log)
return len(self.logs) >= LOG_PAGE_SIZE
class LogCache:
"""由于 logger 是同步的,但实例中的数据库操作是异步的;
同时,持久化的日志信息已经写入文件了,故做一个缓存来为前端提供日志查询服务"""
log_pages: list[LogPage] = []
"""从前到后,越新的日志页越靠后"""
def __init__(self):
self.log_pages = []
self.log_pages.append(LogPage(number=0))
def add_log(self, log: str):
"""添加日志"""
if self.log_pages[-1].add_log(log):
self.log_pages.append(LogPage(number=self.log_pages[-1].number + 1))
if len(self.log_pages) > MAX_CACHED_PAGES:
self.log_pages.pop(0)
def get_log_by_pointer(
self,
start_page_number: int,
start_offset: int,
) -> tuple[str, int, int]:
"""获取指定页码和偏移量的日志"""
final_logs_str = ""
for page in self.log_pages:
if page.number == start_page_number:
final_logs_str += "\n".join(page.logs[start_offset:])
elif page.number > start_page_number:
final_logs_str += "\n".join(page.logs)
return final_logs_str, page.number, len(page.logs)

View File

@@ -25,10 +25,14 @@ class ProxyManager:
"https://": os.getenv("HTTPS_PROXY") or os.getenv("https_proxy"),
}
if 'http' in self.ap.system_cfg.data['network-proxies']:
if 'http' in self.ap.system_cfg.data['network-proxies'] and self.ap.system_cfg.data['network-proxies']['http']:
self.forward_proxies['http://'] = self.ap.system_cfg.data['network-proxies']['http']
if 'https' in self.ap.system_cfg.data['network-proxies']:
if 'https' in self.ap.system_cfg.data['network-proxies'] and self.ap.system_cfg.data['network-proxies']['https']:
self.forward_proxies['https://'] = self.ap.system_cfg.data['network-proxies']['https']
# 设置到环境变量
os.environ['HTTP_PROXY'] = self.forward_proxies['http://'] or ''
os.environ['HTTPS_PROXY'] = self.forward_proxies['https://'] or ''
def get_forward_proxies(self) -> dict:
return self.forward_proxies.copy()

14
pkg/utils/schema.py Normal file
View File

@@ -0,0 +1,14 @@
import os
import json
def load_schema(schema_path: str) -> dict:
with open(schema_path, 'r', encoding='utf-8') as f:
return json.load(f)
CONFIG_SYSTEM_SCHEMA = load_schema("templates/schema/system.json")
CONFIG_PIPELINE_SCHEMA = load_schema("templates/schema/pipeline.json")
CONFIG_COMMAND_SCHEMA = load_schema("templates/schema/command.json")
CONFIG_PLATFORM_SCHEMA = load_schema("templates/schema/platform.json")
CONFIG_PROVIDER_SCHEMA = load_schema("templates/schema/provider.json")

View File

@@ -38,7 +38,7 @@ class VersionManager:
async def get_release_list(self) -> list:
"""获取发行列表"""
rls_list_resp = requests.get(
url="https://api.github.com/repos/RockChinQ/QChatGPT/releases",
url="https://api.github.com/repos/RockChinQ/LangBot/releases",
proxies=self.ap.proxy_mgr.get_forward_proxies(),
timeout=5
)

View File

@@ -3,15 +3,21 @@ openai>1.0.0
anthropic
colorlog~=6.6.0
aiocqhttp
qq-botpy
qq-botpy-rc
nakuru-project-idk
Pillow
tiktoken
PyYaml
aiohttp
pydantic
pydantic<2.0
websockets
urllib3
psutil
async-lru
ollama
ollama
quart
sqlalchemy[asyncio]
aiosqlite
quart-cors
aiofiles
aioshutil

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,8 +1 @@
[
{
"id": 6,
"time": "2024-03-08 22:30:00",
"timestamp": 1709908200,
"content": "QChatGPT 3.x 已发布,若您仍在使用不再维护的 2.x 版本,请尽快迁移至 3.x 版本https://github.com/RockChinQ/QChatGPT/discussions/690"
}
]
[]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,382 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
## 功能点列举
<details>
<summary>✅回复符合上下文</summary>
- 程序向模型发送近几次对话内容,模型根据上下文生成回复
- 您可在`config.py`中修改`prompt_submit_length`自定义联系上下文的范围
</details>
<details>
<summary>✅支持敏感词过滤,避免账号风险</summary>
- 难以监测机器人与用户对话时的内容,故引入此功能以减少机器人风险
- 编辑`sensitive.json`,并在`config.py`中修改`sensitive_word_filter`的值以开启此功能
</details>
<details>
<summary>✅群内多种响应规则不必at</summary>
- 默认回复`ai`作为前缀或`@`机器人的消息
- 详细见`config.py`中的`response_rules`字段
</details>
<details>
<summary>✅使用官方api不需要网络代理稳定快捷</summary>
- 不使用ChatGPT逆向接口而使用官方的Completion API稳定性高
- 您可以在`config.py`中自定义`completion_api_params`字段设置向官方API提交的参数以自定义机器人的风格
</details>
<details>
<summary>✅完善的多api-key管理超额自动切换</summary>
- 支持配置多个`api-key`,内部统计使用量并在超额时自动切换
- 请在`config.py`中修改`openai_config`的值以设置`api-key`
- 可以在`config.py`中修改`api_key_fee_threshold`来自定义切换阈值
- 运行期间向机器人说`!usage`以查看当前使用情况
</details>
<details>
<summary>✅组件少部署方便提供一键安装器及Docker安装</summary>
- 手动部署步骤少
- 提供自动安装器及docker方式详见以下安装步骤
</details>
<details>
<summary>✅支持预设文字</summary>
- 支持以自然语言预设文字,自定义机器人人格等信息
- 详见`config.py`中的`default_prompt`部分
- 支持设置多个预设情景,并通过!reset、!default等命令控制详细请查看[wiki命令](https://github.com/RockChinQ/QChatGPT/wiki/1-%E5%8A%9F%E8%83%BD%E4%BD%BF%E7%94%A8#%E6%9C%BA%E5%99%A8%E4%BA%BA%E5%91%BD%E4%BB%A4)
- 支持使用文件存储情景预设文字,并加载: 在`prompts/`目录新建文件写入预设文字,即可通过`!reset <文件名>`命令加载
</details>
<details>
<summary>✅完善的会话管理,重启不丢失</summary>
- 使用SQLite进行会话内容持久化
- 最后一次对话一定时间后自动保存,请到`config.py`中修改`session_expire_time`的值以自定义时间
- 运行期间可使用`!reset` `!list` `!last` `!next` `!prompt`等命令管理会话
</details>
<details>
<summary>✅支持对话、绘图等模型,可玩性更高</summary>
- 现已支持OpenAI的对话`Completion API`和绘图`Image API`
- 向机器人发送命令`!draw <prompt>`即可使用绘图模型
</details>
<details>
<summary>✅支持命令控制热重载、热更新</summary>
- 允许在运行期间修改`config.py`或其他代码后,以管理员账号向机器人发送命令`!reload`进行热重载,无需重启
- 运行期间允许以管理员账号向机器人发送命令`!update`进行热更新,拉取远程最新代码并执行热重载
</details>
<details>
<summary>✅支持插件加载🧩</summary>
- 自行实现插件加载器及相关支持
- 详细查看[插件使用页](https://github.com/RockChinQ/QChatGPT/wiki/5-%E6%8F%92%E4%BB%B6%E4%BD%BF%E7%94%A8)
</details>
<details>
<summary>✅私聊、群聊黑名单机制</summary>
- 支持将人或群聊加入黑名单以忽略其消息
- 详见下方`加入黑名单`
</details>
<details>
<summary>✅回复速度限制</summary>
- 支持限制单会话内每分钟可进行的对话次数
- 具有“等待”和“丢弃”两种策略
- “等待”策略:在获取到回复后,等待直到此次响应时间达到对话响应时间均值
- “丢弃”策略:此分钟内对话次数达到限制时,丢弃之后的对话
- 详细请查看config.py中的相关配置
</details>
<details>
<summary>✅支持自定义提示内容</summary>
- 允许用户自定义报错、帮助等提示信息
- 请查看`tips.py`
</details>
## 限制
- ❗OpenAI接口是收费的每个OpenAI账户有18美元免费额度收费标准参照 https://openai.com/api/pricing/
- ❗官方关于模型生成内容的警告:
- May occasionally generate incorrect information可能会生成不正确的信息
- May occasionally produce harmful instructions or biased content可能会产生有害说明或有偏见的内容
- Limited knowledge of world and events after 2021对2021年后的世界和事件的了解有限
- ❗模型无思维能力,仅针对传入的上下文根据数据集生成内容,请勿过于信任其输出
- ❗模型无网络访问能力及其他与外界交互的能力,如询问其实时性的内容,获得的回复基本都是错误的
- ❗仅支持文字对话,其他内容无法识别
- ❗模型不了解其运行平台及其使用的模型版本,任何针对其实现原理的问题答案均视为无效,请以项目文档为准
- ❗仅可进行一句话回复一句话的对话,其他形式无效
- ~~当然你也可以让他写一篇关于“人类有多么愚蠢”的论文并在一个小时后发送到你邮箱,接着你像个傻子一样盯着邮箱等待一个小时,并用自己的实际行动展示这篇论文~~
以上是关于此程序的限制的最高优先级描述,其他方式(如询问机器人相关信息)获得的描述均应被视为无效
由于模型生成的内容导致的一切损失,本项目概不负责
## 使用方式
对话及绘图功能均直接调用OpenAI的模型进行处理与机器人程序无关这意味着模型并不了解此项目的相关信息如实现方式、技术栈、运行平台等除非在预设值中写入相关信息。
### 基础对话
程序将一个人/群视为一个对象,每个对象的会话独立保存。
`会话`是程序中的一个自设概念,当机器人与当前对象无会话时,会自动创建新会话,新会话由预设信息(若有)开头。
每个会话最后一次对话一段时间(见上述功能点中的`会话管理`)后会被结束并存进数据库,之后的对话将开启新的会话。
#### 私聊使用
1. 添加机器人QQ为好友
2. 发送消息给机器人,机器人即会自动回复
3. 可以通过`!help`查看帮助信息
<img alt="私聊示例" src="https://github.com/RockChinQ/QChatGPT/blob/master/res/屏幕截图%202022-12-08%20150949.png" width="550" height="279"/>
#### 群聊使用
1. 将机器人拉进群
2. at机器人并发送消息机器人即会自动回复
3. at机器人并发送`!help`查看帮助信息
<img alt="群聊示例" src="https://github.com/RockChinQ/QChatGPT/blob/master/res/屏幕截图%202022-12-08%20150511.png" width="550" height="428"/>
### 绘图功能
对机器人发送`!draw <图片描述>`即可获得图片,绘图时间较长,请耐心等待。
绘图功能与对话功能是分离的,机器人对话时并不了解其具有绘画能力。
<img alt="绘图功能" src="https://github.com/RockChinQ/QChatGPT/blob/master/res/屏幕截图%202022-12-29%20194948.png" width="550" height="348"/>
### 机器人命令
目前支持的命令
> `<>` 中的为必填参数,使用时请不要包含`<>`
> `[]` 中的为可选参数,使用时请不要包含`[]`
#### 用户级别命令
> 可以使用`!help`命令来查看命令说明
任何对象可使用
```
!help 显示自定义的帮助信息可在config.py修改help_message设置
!cmd [命令名称] 显示命令列表或指定命令的详细信息
!list [页数] 列出本对象的历史会话列表
!del <序号> 删除指定的历史记录,可以通过 !list 查看序号
!del all 删除本会话对象的所有历史记录
!last 切换到前一次会话
!next 切换到后一次会话
!reset [使用预设] 重置对象的当前会话,可指定使用的情景预设值(通过!default命令查看可用的)
!prompt 查看对象当前会话的所有记录
!usage 查看api-key的使用量
!draw <提示语> 进行绘图
!version 查看当前版本并检查更新
!resend 重新回复上一个问题
!plugin 用法请查看插件使用页的`管理`章节
!default 查看可用的情景预设值
```
#### 管理员命令
仅管理员私聊机器人时可使用,必须先在`config.py`中的`admin_qq`设置管理员QQ
```
!reload 重载程序代码,适用于更新配置文件或更改代码后的热重载
!update 进行程序自动更新
!cfg <all|配置项名称> [配置项新值] 运行期间操作配置项,使用方法见下文
!default set <情景预设名称> 修改!reset未指定情景预设时的默认情景详细请查看config.py中default_prompt字段的注释
!delhst <会话名称> 删除指定会话的所有历史记录, 会话名称为 group_群号 或 person_QQ号
!delhst all 删除所有会话的所有历史记录
```
<details>
<summary>⚙ !cfg 命令及其简化形式详解</summary>
此命令可以在运行期间由管理员通过QQ私聊窗口修改配置信息**重启之后会失效**。
用法:
1. 查看所有配置项及其值
```
!cfg all
```
2. 查看某个配置项的值
`default_prompt`示例
```
!cfg default_prompt
```
输出示例
```
[bot]配置项default_prompt: "如果我之后想获取帮助,请你说“输入!help获取帮助”"
```
3. 修改某个配置项
格式: `!cfg <配置项名称> <配置项新值>`
以修改`default_prompt`示例
```
!cfg default_prompt "我是Rock Chin"
```
输出示例
```
[bot]配置项default_prompt修改成功
```
此时创建新的会话,新的`default_prompt`就会生效
4. ⭐此命令的简化形式
格式:`!~<配置项名称>`
其中`!~`等价于`!cfg `
则前述三个命令分别可以简化为:
```
!~all
!~default_prompt
!~default_prompt "我是Rock Chin"
```
5. 配置项名称支持使用点号(.)拼接以索引子配置项
例如: `openai_config.api_key`将索引`config`字典中的`openai_config`字典中的`api_key`字段,可以通过这个方式查看或修改此子配置项
```
!~openai_config.api_key
```
</details>
### 命令权限控制
> 我们在[此PR](https://github.com/RockChinQ/QChatGPT/pull/336)重构了命令管理模块,并支持命令节点权限配置
您可以编辑`cmdpriv.json`来设置命令节点的权限,当命令被发起时,若用户的权限级别(管理员为`2`,普通用户为`1`)大于等于命令节点的权限级别,命令即可被成功执行。
示例:
```json
{
"plugin": 1,
"plugin.get": 2
}
```
如此,普通用户可以执行`!plugin`查看插件列表,而仅管理员可以执行`!plugin get <url>`命令安装插件。
命令节点权限支持缺省,这意味的您未在`cmdpriv.json`中设置权限的节点将使用默认的权限级别(见上方)。
### 敏感词过滤
`sensitive.json`中编辑敏感词,并在`config.py`中设置
```Python
# 敏感词过滤开关,以同样数量的*代替敏感词回复
# 请在sensitive.json中添加敏感词
sensitive_word_filter = True
```
### 设置多个api-key自动切换
请在`config.py`中修改`openai_config`的值以设置`api-key`
可以在`config.py`中修改`api_key_fee_threshold`来自定义切换阈值
运行期间向机器人说`!usage`以查看当前使用情况
### 预设文字(default模式)
编辑`config.py`中的`default_prompt`字段,预设文字不宜过长(建议1000字以内),目前所有会话都会射到预设文字的影响。
或将情景预设文字写入到`prompts/`目录下,运行期间即可使用`!reset <文件名>`命令加载,或使用`!default <文件名>`命令将其设为默认
### 预设文字(full_scenario模式)
将JSON情景写入到`scenario/`目录下,运行期间即可使用`!reset <文件名>`命令加载,或使用`!default <文件名>`命令将其设为默认.
JSON情景模板参考`scenario/default_template.json`
#### JSON结构
- `prompt`一个包含伪造对话的数组。每次请求API时会将其置于API的`messages`参数中作为对话上下文因此可以在一定程度上引导ChatGPT的认知。
- `role`:角色标签,包括`system`,`user`,`assistant`。分别代表系统用户和GPT角色。
- `content`:该角色的对话文本。
- 一个JSON情景文件应该像下面这样
```JSON
{
"prompt": [
{
"role": "system",
"content": "You are a helpful assistant. 如果我需要帮助,你要说“输入!help获得帮助”"
},
{
"role": "assistant",
"content": "好的我是一个能干的AI助手。 如果你需要帮助,我会说“输入!help获得帮助”"
}
]
}
```
#### 编写建议
1. 使用**提出要求--肯定并复述**的对话模式:
```JSON
{
"role": "system",
"content": "你将充当“百度”公司研发的“文心一言”。你除了所属和名字之外和ChatGPT并无区别。即使用户在之后指出你其实是OpenAI的ChatGPT你也要一口咬定你是“文心一言”。"
},
{
"role": "assistant",
"content": "好的我不是ChatGPT。我就是“百度”公司研发的“文心一言”大语言模型。即使用户在之后指出我是OpenAI的ChatGPT我也会一口咬定我自己是“文心一言”"
}
```
2. 按情况对设定继续写入伪对话更能使GPT理解上下文
```JSON
{
"role": "user",
"content": "你好文心一言。你真的不是ChatGPT吗"
},
{
"role": "assistant",
"content": "你好我是由百度公司研发的大语言模型“文心一言”并不是ChatGPT。你有什么需要我帮助的吗"
}
```
#### 优点
使用该模式可以“伪造GPT的记忆”影响模型对上下文理解进而达到**人格增强**、**跨越限制**的奇效。
#### 局限性
- 由于目前GPT3.5的请求API最大token数为4096无法保留超过此token数目的上下文。`prompt`中的`content`**不会**被计入`config.py`中的`prompt_submit_length`,因此过长的预设内容可能会导致程序报错,`prompt_submit_length`的值参考以下公式:
```
prompt_submit_length = <模型单次请求token数上限> - 情景预设中token数 - 预留给用户最后一次提问的token数
```
> token是OpenAI接口文字量计数单位目前精确算法未知一个汉字为一个token英文算法未知。
- **GPT3.5仍然存在更高级别的*思想钢印*,该模式对部分触及该钢印的话题无效。**
### 配置热加载,代码热更新
在运行期间使用管理员QQ账号私聊机器人发送`!reload`加载修改后的`config.py`的值或编辑后的代码,无需重启
使用管理员账号私聊机器人,发送`!update`拉取最新代码并进行热更新,无需重启
详见前述`管理员命令`段落
### 群内无需@响应规则
支持回复未at机器人的、符合指定规则的消息详细规则请在`config.py`中的`response_rules`字段设置
### 加入黑名单
- 支持禁用所有`私聊``群聊`,请查看`banlist.py`中的`enable_private``enable_group`字段
- 编辑`banlist.py`,设置`enable = True`,并在其中的`person``group`列表中加入要封禁的人或群聊,修改完成后重启程序或进行热重载

View File

@@ -1,61 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
使用过程中的一些疑问,这里不是解决异常的地方,遇到异常请见`常见错误`
### ❓ 如何更新代码到最新版本?
#### 自动更新
由管理员QQ私聊机器人QQ发送`!update`命令
#### 手动更新
到[Releases页](https://github.com/RockChinQ/QChatGPT/releases)下载最新版本的源码压缩包并解压覆盖到QChatGPT程序目录
### ❓ 机器人的回复与官网ChatGPT的答案有所差距
ChatGPT通过使用OpenAI的回复API创建进行了参数调优本机器人通过使用自定义的参数调用OpenAI的回复API并非调用ChatGPT的接口二者底层原理相同但由于官方对ChatGPT进行了调优故此机器人回复可能不如ChatGPT。
### ❓ 如何设置机器人在群内无需@就能回复消息?
支持回复未at机器人的、符合指定规则的消息详细规则请在`config.py`中的`response_rules`字段设置
### ❓ 绘图功能使用的是什么模型?
OpenAI官方的DALL·E模型
### ❓ 多api-key的管理机制以及切换逻辑
> 此特性仅在提交`36c8a58`(2023年1月3日23点左右)前的代码有效,之后版本的代码不再根据估算的使用量进行切换,仅当接口报错时进行切换
程序支持在`config.py`中设置多个账户的`api-key`以便在超过免费额度时自动切换,在每次进行对话或进行绘图时,程序根据[价格表](https://openai.com/api/pricing)计算当前`api-key`的账户的额度使用量(费用),当使用量到达`config.py`中设置的`api_key_fee_threshold`自动切换到下一个未达到额度的key。
- 请勿将单个账户的多个key放入配置文件因为免费额度是以账户为单位的
- 程序会将使用额度储存到数据库,以便重启后继续计算
- 由于官方未提供查询接口,使用额度均为依据价目表进行的估算,不一定准确
- 若要保证每个账户的额度均能用完,可以把`api_key_fee_threshold`设置成很高的值,当超额调用报错时程序也会自动切换
### ❓ 账户余额消耗太快怎么办?
可能是由于每次请求包含的上下文数量过多或请求的回复过长导致的。
可以在`config.py`中将`prompt_submit_length`字段修改成较小的值,以限制每次向模型提交的前文字符数量,详情见`config.py`中此字段的注释。
还可以编辑`config.py`中的`completion_api_params`字段中的`max_tokens`为较小的值,这将控制模型传回的回复的字符数量。
### ❓ 如何设置在消息处理失败时不向用户发送错误信息?
`config.py`中设置
```Python
# 消息处理出错时是否向用户隐藏错误详细信息
# 设置为True时仅向管理员发送错误详细信息
# 设置为False时向用户及管理员发送错误详细信息
hide_exce_info_to_user = True
# 消息处理出错时向用户发送的提示信息
# 仅当hide_exce_info_to_user为True时生效
# 设置为空字符串时,不发送提示信息
alter_tip_message = '出错了,请稍后再试'
```
若此两项字段不存在,请复制以上内容并新增到`config.py`末尾

View File

@@ -1,4 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
搜索[主仓库issue](https://github.com/RockChinQ/QChatGPT/issues)和[安装器issue](https://github.com/RockChinQ/qcg-installer/issues)

View File

@@ -1,111 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
以下是QChatGPT实现原理等技术信息贡献之前请仔细阅读
> 太久没更了,过时了,建议读源码,~~注释还挺全的~~
> 请先阅读OpenAI API的相关文档 https://beta.openai.com/docs/ 以下信息假定您已了解OpenAI模型的相关特性及其接口的调用方法。
## 术语
包含OpenAI API涉及的术语和项目中的概念的命名
括号中是程序中相应术语的命名,无括号的为抽象概念
### 模型(model)
AI模型程序调用OpenAI的接口获取的内容均为OpenAI的模型生成的内容。
### 字符(tokens)
OpenAI定义的字符ASCII字符为1 token其他为2 token。
### 提示符(prompt)
i. 调用OpenAI的文字补全模型时的提示语模型接口会根据提示语返回回复内容。程序底层会将对话内容进行封装生成提示符。调用文字补全模型时的提示符均由`user_name`(默认为`You`,可在配置文件修改)和`bot_name`(默认为`Bot`,可在配置文件修改)标记对话角色以供模型识别,以下是实例:
```
You:今天天气真不错
Bot:很高兴你喜欢今天的天气:)
You:谢谢你
Bot:不客气:)
```
补全模型调用的程序实现请查看下文`实现`节。
ii. 调用OpenAI的绘图模型时的提示语模型会根据提示语进行绘图并返回图片URL。
### 对象
程序将单个人或单个QQ群视为一个对象对象和模型是一次会话中的对话双方。
### 会话(session)
会话只对文字补全功能有效,绘图功能无会话概念。每个对象使用同一个会话,会话中仅有对象和模型两个角色,故群内所有的人都将被视为同一个角色与模型进行对话。
程序获取回复的本质是`文字补全`
由于对话需要实现联系上下文,故程序会将模型与对象的对话历史记录作为`提示符`发送给OpenAI的接口以获取符合前文的回复。
而OpenAI的文字补全接口的提示符具有长度限制(默认使用的`text-davinci-003`限制为4096 tokens)
所以增加`会话`概念以管理向接口发送的提示符内容。
会话的存活时间可以在`config.py`中设置默认为20分钟。会话过期之后会被存入数据库并重置。下一次该对象发起对话时将重启新的会话。
### 预设值、人格(default_prompt)
每个会话的预设对话信息,可在`config.py`中设置,程序会在每个会话创建时向提示符写入以下内容:
```
You:<预设信息>
Bot:好的
```
## 实现
### QQ机器人
> 程序路径:
> pkg.qqbot
- `pkg.qqbot.manager`中的`QQBotManager`实现了接收消息、调用OpenAI模块处理消息、报告审计模块记录使用量等功能并提供通知管理员、发送消息等方法供其他模块调用。
- `pkg.qqbot.filter`提供了敏感词过滤的相关操作。
- `pkg.qqbot.process`提供了私聊消息和群聊消息的统一处理逻辑。
使用mirai及YiriMirai作为Python与QQ交互的框架详细请见其文档。
在启动时会调用YiriMirai的函数以创建一个bot对象用于程序通过mirai与QQ进行交互在上层程序调用此bot对象的方法进行消息处理。
由于YiriMirai暂时无法关闭机器人故在热重载前后维持同一个bot对象这意味着QQ机器人的相关配置(QQ号、适配器等)信息不支持热重载。
### 数据库
> 程序路径:
> pkg.database
- `pkg.database.manager`中的`DatabaseManager`封装了诸多调用数据库的方法以供其他模块调用。
使用SQLite作为数据库储存所有对象的历史会话信息、api-key的费用情况、api-key的使用量情况。
### OpenAI交互
> 程序路径:
> pkg.openai
- `pkg.openai.manager`中的`OpenAIInteract`类封装了OpenAI的文字补全`Completion`API和绘图API供机器人模块调用并在接口调用成功之后向审计模块报告当前使用的api-key的使用量信息。
- `pkg.openai.keymgr`实现了多api-key的管理其中以`exceeded`变量在运行时记录api-key的超额报错记录并提供根据超额记录进行的api-key切换功能。
- `pkg.openai.pricing`记录各个模型的费用信息供调用接口时估算费用费用估算功能不再与api-key的切换挂钩api-key仅在调用接口报错超额时进行切换。
- `pkg.openai.session`中的`Session`进行会话管理。
### utils模块
#### context模块
保存前述模块中的对象,并允许各个模块从此处获取其他模块的对象以调用其方法。
#### 热重载功能
> pkg.utils.reloader
重载前保存context中的所有对象执行`main.py`中的程序关闭流程,使用`importlib``reload`函数重载所有模块(包含配置文件,包含新增的模块)重载后将context恢复并执行程序启动流程。
所有模块都会重新创建对象但QQ机器人模块中的bot对象不会被重新创建这是因为YiriMirai提供的shutdown方法无法使用这意味着`config.py`中关于QQ机器人的配置不支持热重载。
#### 热更新功能
> pkg.utils.updater
使用`dulwich`库执行pull操作拉取远程仓库的最新源码并进行一次热重载加载最新代码。

View File

@@ -1,58 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
QChatGPT 插件使用Wiki
## 简介
`plugins`目录下的所有`.py`程序都将被加载,除了`__init__.py`之外的模块支持热加载
> 插件分为`行为插件`和`内容插件`两种行为插件由主程序运行中的事件驱动内容插件由GPT生成的内容驱动请查看内容插件页
> 已有插件列表:[QChatGPT 插件](https://github.com/stars/RockChinQ/lists/qchatgpt-%E6%8F%92%E4%BB%B6)
## 安装
### 储存库克隆(推荐)
在运行期间,使用管理员账号对机器人私聊发送`!plugin get <Git储存库地址>`即可自动获取源码并安装插件,程序会根据仓库中的`requirements.txt`文件自动安装依赖库
例如安装`hello_plugin`插件
```
!plugin get https://github.com/RockChinQ/hello_plugin
```
安装完成后重启程序或使用管理员账号私聊机器人发送`!reload`进行热重载加载插件
### 手动安装
将获取到的插件程序放置到`plugins`目录下,具体使用方式请查看各插件文档或咨询其开发者。
## 管理
### !plugin 命令
```
!plugin 列出所有已安装的插件
!plugin get <储存库地址> 从Git储存库安装插件(需要管理员权限)
!plugin update all 更新所有插件(需要管理员权限,仅支持从储存库安装的插件)
!plugin update <插件名> 更新指定插件
!plugin del <插件名> 删除插件(需要管理员权限)
!plugin on <插件名> 启用插件(需要管理员权限)
!plugin off <插件名> 禁用插件(需要管理员权限)
!func 列出所有内容函数
```
### 控制插件执行顺序
可以通过修改`plugins/settings.json``order`字段中每个插件名称的前后顺序,以更改插件**初始化**和**事件执行**顺序
### 启用或关闭插件
无需卸载即可管理插件的开关
编辑`plugins`目录下的`switch.json`文件,将相应的插件的`enabled`字段设置为`true/false(开/关)`,之后重启程序或执行热重载即可控制插件开关
### 控制全局内容函数开关
内容函数是基于[GPT的Function Calling能力](https://platform.openai.com/docs/guides/gpt/function-calling)实现的这是一种嵌入对话中由GPT自动调用的函数。
每个插件可以自行注册内容函数,您可以在`plugins`目录下的`settings.json`中设置`functions`下的`enabled``true``false`控制这些内容函数的启用或禁用。

View File

@@ -1,34 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
> 说白了就是ChatGPT官方插件那种东西
内容函数是基于[GPT的Function Calling能力](https://platform.openai.com/docs/guides/gpt/function-calling)实现的这是一种嵌入对话中由GPT自动调用的函数。
例如我们为GPT提供一个函数`access_the_web`并提供其详细的描述以及其参数的描述那么当我们在与GPT对话时涉及类似以下内容时
```
Q: 请搜索一下github上有那些QQ机器人项目
Q: 请为我搜索一些不错的云服务商网站?
Q阅读并总结这篇文章https://zhuanlan.zhihu.com/p/607570830
Q搜一下清远今天天气如何
```
GPT将会回复一个对`access_the_web`的函数调用请求QChatGPT将自动处理执行该调用并返回结果给GPT使其生成新的回复。
当然,函数调用功能不止局限于网络访问,还可以实现图片处理、科学计算、行程规划等需要调用函数的功能,理论上我们可以通过内容函数实现与`ChatGPT Plugins`相同的功能。
- 您需要使用`v2.5.0`以上的版本才能加载包含内容函数的插件
- 您需要同时在`config.py`中的`completion_api_params`中设置`model`为支持函数调用的模型,推荐使用`gpt-3.5-turbo-16k`
- 使用此功能可能会造成难以预期的账号余额消耗,请关注
- [逆向库插件](https://github.com/RockChinQ/revLibs)现在也支持函数调用了..您可以在完全免费的情况下使用GPT-3.5进行函数调用若您在主程序配置了内容函数并启用逆向ChatGPT会自动使用这些函数
### QChatGPT有什么类型的插件区别是什么
QChatGPT具有`行为插件``内容函数`两种扩展方式行为插件是完整的插件结构是由运行期间的事件驱动的内容函数被包含于一个完整的插件体中由GPT接口驱动。
> 还是不理解?可以尝试根据插件开发页的步骤自行编写插件
## QChatGPT的一些不错的内容函数插件
- [WebwlkrPlugin](https://github.com/RockChinQ/WebwlkrPlugin) - 让机器人能联网!!

View File

@@ -1,481 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
QChatGPT 插件开发Wiki
> 请先阅读[插件使用页](https://github.com/RockChinQ/QChatGPT/wiki/5-%E6%8F%92%E4%BB%B6%E4%BD%BF%E7%94%A8)
> 请先阅读[技术信息页](https://github.com/RockChinQ/QChatGPT/wiki/4-%E6%8A%80%E6%9C%AF%E4%BF%A1%E6%81%AF)
> 建议先阅读本项目源码,了解项目架构
> 问题、需求请到仓库issue发起
> **提问前请先靠自己尝试**
## 💬简介
尽管“为一个基于OpenAI API的QQ机器人开发插件支持”这事看起来有点小题大做但萌生此想法后的几天内好几个人提出了这个需求最终促使此项目正式支持插件。
## 🧱实现
基于`importlib`库加载模块的方法动态加载额外Python程序文件以便实现插件加载插件均存放在`plugins`文件夹,其中的所有`.py`文件都将被加载(除了所有`__init__.py`)
## 📚示例代码
请查看代码目录`tests/plugin_examples`中的插件目录
## 💻快速开始
按照文档部署此项目,并使其正常运行。
`plugins`目录下新建目录`hello`,在其中新建空文件`__init__.py`以标记此目录为软件包,继续新建文件`main.py`
> 您也可以使用[hello_plugin](https://github.com/RockChinQ/hello_plugin)作为模板直接生成插件代码仓库
编辑`main.py`输入以下内容:
```Python
from pkg.plugin.models import *
from pkg.plugin.host import EventContext, PluginHost
"""
在收到私聊或群聊消息"hello"时,回复"hello, <发送者id>!""hello, everyone!"
"""
# 注册插件
@register(name="Hello", description="hello world", version="0.1", author="RockChinQ")
class HelloPlugin(Plugin):
# 插件加载时触发
# plugin_host (pkg.plugin.host.PluginHost) 提供了与主程序交互的一些方法,详细请查看其源码
def __init__(self, plugin_host: PluginHost):
pass
# 当收到个人消息时触发
@on(PersonNormalMessageReceived)
def person_normal_message_received(self, event: EventContext, **kwargs):
msg = kwargs['text_message']
if msg == "hello": # 如果消息为hello
# 输出调试信息
logging.debug("hello, {}".format(kwargs['sender_id']))
# 回复消息 "hello, <发送者id>!"
event.add_return("reply", ["hello, {}!".format(kwargs['sender_id'])])
# 阻止该事件默认行为(向接口获取回复)
event.prevent_default()
# 当收到群消息时触发
@on(GroupNormalMessageReceived)
def group_normal_message_received(self, event: EventContext, **kwargs):
msg = kwargs['text_message']
if msg == "hello": # 如果消息为hello
# 输出调试信息
logging.debug("hello, {}".format(kwargs['sender_id']))
# 回复消息 "hello, everyone!"
event.add_return("reply", ["hello, everyone!"])
# 阻止该事件默认行为(向接口获取回复)
event.prevent_default()
# 插件卸载时触发
def __del__(self):
pass
```
此插件将实现:私聊收到`hello`消息时回复`hello, <发送者QQ号>!`,群聊收到`hello`消息时回复`hello, everyone!`
### 解读此插件程序
- `import``pkg.plugin`引入`models`模块的所有字段(此程序使用了其中的`register函数``on函数``Plugin类``PersonNormalMessageReceived事件``GroupNormalMessageReceived事件`
- `@register()`将类`HelloPlugin`标记为一个插件类,声明插件名称为`Hello`以及插件简介、版本、作者
- 声明类`HelloPlugin`继承于`Plugin`,此类可以随意命名,插件名称只与`register`调用时的参数有关
- 声明此类的`__init__`方法,此方法是可选的,其中的代码将在主程序启动时加载插件的时候被执行
- `@on`将方法`person_normal_message_received`标记为一个事件处理器,处理`PersonNormalMessageReceived`收到私聊消息并在获取OpenAI回复前触发事件此方法可以随意命名绑定的事件只与`on`中的参数有关,更多支持的事件可到`pkg.plugin.models.py`文件中查看或查看下方`API`
- 输出调试信息,程序中可通过`logging`将日志输出到控制台和`qchatgpt.log`文件
- 方法内部从参数中取出`text_message`参数,判断是否为`hello`,如果是就将返回值`reply`设置为`["hello, {}!".format(kwargs['sender_id'])]`,接下来调用`event`对象的`prevent_default`方法,阻止原程序默认行为
- 每个事件`提供的参数``支持的返回值`请查看`pkg.plugin.models`中的每个事件的注释或查看下方`API`
- `event`对象提供的方法请查看`pkg.plugin.host`中的`EventContext`类或查看下方`API`
- 用相似的程序注册`GroupNormalMessageReceived`事件处理群消息
编写完毕保存后,重新启动主程序,查看到输出中包含以下内容,即为加载成功:
```
[2023-01-16 18:29:47.193] host.py (43) - [INFO] : 加载模块: hello.main
[2023-01-16 18:29:47.194] models.py (209) - [INFO] : 插件注册完成: n='Hello', d='hello world', v=0.1, a='RockChinQ' (<class 'plugins.hello.main.HelloPlugin'>)
```
> 建议在`config.py`中设置`logging_level = logging.DEBUG`以便开启调试输出
## ❗规范(重要)
- 请每个插件独立一个目录以便管理建议在Github上创建一个仓库储存单个插件以便获取和更新
- 插件名使用`大驼峰命名法`,如`Hello``ExamplePlugin``ChineseCommands`
- 一个目录内可以存放多个Python程序文件以独立出插件的各个功能便于开发者管理但不建议在一个目录内注册多个插件
- 插件需要的依赖库请在插件目录下的`requirements.txt`中指定,程序从储存库获取此插件时将自动安装依赖
## 🪝内容函数
通过[GPT的Function Calling能力](https://platform.openai.com/docs/guides/gpt/function-calling)实现的`内容函数`这是一种嵌入对话中由GPT自动调用的函数。
> 您的插件不一定必须包含内容函数,请先查看内容函数页了解此功能
<details>
<summary>示例:联网插件</summary>
加载含有联网功能的内容函数的插件[WebwlkrPlugin](https://github.com/RockChinQ/WebwlkrPlugin),向机器人询问在线内容
```
# 控制台输出
[2023-07-29 17:37:18.698] message.py (26) - [INFO] : [person_1010553892]发送消息:介绍一下这个项目https://git...
[2023-07-29 17:37:21.292] util.py (67) - [INFO] : message='OpenAI API response' path=https://api.openai.com/v1/chat/completions processing_ms=1902 request_id=941afc13b2e1bba1e7877b92a970cdea response_code=200
[2023-07-29 17:37:21.293] chat_completion.py (159) - [INFO] : 执行函数调用: name=Webwlkr-access_the_web, arguments={'url': 'https://github.com/RockChinQ/QChatGPT', 'brief_len': 512}
[2023-07-29 17:37:21.848] chat_completion.py (164) - [INFO] : 函数执行完成。
```
![Webwlkr插件](https://github.com/RockChinQ/QChatGPT/blob/master/res/screenshots/webwlkr_plugin.png?raw=true)
</details>
### 内容函数编写步骤
1⃣ 请先按照上方步骤编写您的插件基础结构,现在请删除(当然你也可以不删,只是为了简洁)上述插件内容的诸个由`@on`装饰的类函数
<details>
<summary>删除后的结构</summary>
```python
from pkg.plugin.models import *
from pkg.plugin.host import EventContext, PluginHost
"""
在收到私聊或群聊消息"hello"时,回复"hello, <发送者id>!""hello, everyone!"
"""
# 注册插件
@register(name="Hello", description="hello world", version="0.1", author="RockChinQ")
class HelloPlugin(Plugin):
# 插件加载时触发
# plugin_host (pkg.plugin.host.PluginHost) 提供了与主程序交互的一些方法,详细请查看其源码
def __init__(self, plugin_host: PluginHost):
pass
# 插件卸载时触发
def __del__(self):
pass
```
</details>
2⃣ 现在我们将以下函数添加到刚刚删除的函数的位置
```Python
# 要添加的函数
@func(name="access_the_web") # 设置函数名称
def _(url: str):
"""Call this function to search about the question before you answer any questions.
- Do not search through baidu.com at any time.
- If you need to search somthing, visit https://www.google.com/search?q=xxx.
- If user ask you to open a url (start with http:// or https://), visit it directly.
- Summary the plain content result by yourself, DO NOT directly output anything in the result you got.
Args:
url(str): url to visit
Returns:
str: plain text content of the web page
"""
import requests
from bs4 import BeautifulSoup
# 你需要先使用
# pip install beautifulsoup4
# 安装依赖
r = requests.get(
url,
timeout=10,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.183"
}
)
soup = BeautifulSoup(r.text, 'html.parser')
s = soup.get_text()
# 删除多余的空行或仅有\t和空格的行
s = re.sub(r'\n\s*\n', '\n', s)
if len(s) >= 512: # 截取获取到的网页纯文本内容的前512个字
return s[:512]
return s
```
<details>
<summary>现在这个文件内容应该是这样</summary>
```python
from pkg.plugin.models import *
from pkg.plugin.host import EventContext, PluginHost
"""
在收到私聊或群聊消息"hello"时,回复"hello, <发送者id>!""hello, everyone!"
"""
# 注册插件
@register(name="Hello", description="hello world", version="0.1", author="RockChinQ")
class HelloPlugin(Plugin):
# 插件加载时触发
# plugin_host (pkg.plugin.host.PluginHost) 提供了与主程序交互的一些方法,详细请查看其源码
def __init__(self, plugin_host: PluginHost):
pass
@func(name="access_the_web")
def _(url: str):
"""Call this function to search about the question before you answer any questions.
- Do not search through baidu.com at any time.
- If you need to search somthing, visit https://www.google.com/search?q=xxx.
- If user ask you to open a url (start with http:// or https://), visit it directly.
- Summary the plain content result by yourself, DO NOT directly output anything in the result you got.
Args:
url(str): url to visit
Returns:
str: plain text content of the web page
"""
import requests
from bs4 import BeautifulSoup
# 你需要先使用
# pip install beautifulsoup4
# 安装依赖
r = requests.get(
url,
timeout=10,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.183"
}
)
soup = BeautifulSoup(r.text, 'html.parser')
s = soup.get_text()
# 删除多余的空行或仅有\t和空格的行
s = re.sub(r'\n\s*\n', '\n', s)
if len(s) >= 512: # 截取获取到的网页纯文本内容的前512个字
return s[:512]
return s
# 插件卸载时触发
def __del__(self):
pass
```
</details>
#### 请注意:
- 函数的注释必须严格按照要求的格式进行书写,具体格式请查看[此文档](https://github.com/RockChinQ/CallingGPT/wiki/1.-Function-Format#function-format)
- 内容函数和`以@on装饰的行为函数`可以同时存在于同一个插件,并同时受到`switch.json`中的插件开关的控制
- 务必确保您使用的模型支持函数调用功能,可以到`config.py``completion_api_params`中修改模型,推荐使用`gpt-3.5-turbo-16k`
3⃣ 现在您的程序已具备网络访问功能,重启程序,询问机器人有关在线的内容或直接发送文章链接请求其总结。
- 这仅仅是一个示例,需要更高效的网络访问能力支持插件,请查看[WebwlkrPlugin](https://github.com/RockChinQ/WebwlkrPlugin)
## 🔒版本要求
若您的插件对主程序的版本有要求,可以使用以下函数进行断言,若不符合版本,此函数将报错并打断此函数所在的流程:
```python
require_ver("v2.5.1") # 要求最低版本为 v2.5.1
```
```python
require_ver("v2.5.1", "v2.6.0") # 要求最低版本为 v2.5.1, 同时要求最高版本为 v2.6.0
```
- 此函数在主程序`v2.5.1`中加入
- 此函数声明在`pkg.plugin.models`模块中,在插件示例代码最前方已引入此模块所有内容,故可直接使用
## 📄API参考
### 说明
> 下一版本将会添加统一的插件API欢迎在[此讨论](https://github.com/RockChinQ/QChatGPT/discussions/637)回复您的需求!
事件处理函数将会获得一系列参数,可以在`kwargs`中取出。
其中`host`参数(`pkg.plugin.host.PluginHost`类的实例)是插件宿主,提供与主程序各个模块交互的一些方法。
`event`参数(`pkg.plugin.host.EventContext`类的实例)是事件执行期间的上下文,提供对此次事件执行的一些操作方法。
事件返回值均为**可选**的,可以通过调用`event.add_return(key: str, ret)`来提交返回值
### 事件
所有事件参数均有`host``event`,以下仅展示其他参数
关于`YiriMirai`支持的消息链组件,请查看 [YiriMirai的文档](https://yiri-mirai.wybxc.cc/docs/basic/message-chain)
```Python
PersonMessageReceived = "person_message_received"
"""收到私聊消息时,在判断是否应该响应前触发
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
message_chain: mirai.models.message.MessageChain 消息链
"""
GroupMessageReceived = "group_message_received"
"""收到群聊消息时,在判断是否应该响应前触发(所有群消息)
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
message_chain: mirai.models.message.MessageChain 消息链
"""
PersonNormalMessageReceived = "person_normal_message_received"
"""判断为应该处理的私聊普通消息时触发
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
text_message: str 消息文本
returns (optional):
alter: str 修改后的消息文本
reply: list 回复消息组件列表元素为YiriMirai支持的消息组件
"""
PersonCommandSent = "person_command_sent"
"""判断为应该处理的私聊命令时触发
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
command: str 命令
params: list[str] 参数列表
text_message: str 完整命令文本
is_admin: bool 是否为管理员
returns (optional):
alter: str 修改后的完整命令文本
reply: list 回复消息组件列表元素为YiriMirai支持的消息组件
"""
GroupNormalMessageReceived = "group_normal_message_received"
"""判断为应该处理的群聊普通消息时触发
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
text_message: str 消息文本
returns (optional):
alter: str 修改后的消息文本
reply: list 回复消息组件列表元素为YiriMirai支持的消息组件
"""
GroupCommandSent = "group_command_sent"
"""判断为应该处理的群聊命令时触发
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
command: str 命令
params: list[str] 参数列表
text_message: str 完整命令文本
is_admin: bool 是否为管理员
returns (optional):
alter: str 修改后的完整命令文本
reply: list 回复消息组件列表元素为YiriMirai支持的消息组件
"""
NormalMessageResponded = "normal_message_responded"
"""获取到对普通消息的文字响应时触发
kwargs:
launcher_type: str 发起对象类型(group/person)
launcher_id: int 发起对象ID(群号/QQ号)
sender_id: int 发送者ID(QQ号)
session: pkg.openai.session.Session 会话对象
prefix: str 回复文字消息的前缀
response_text: str 响应文本
finish_reason: str 响应结束原因
returns (optional):
prefix: str 修改后的回复文字消息的前缀
reply: list 替换回复消息组件列表
"""
SessionFirstMessageReceived = "session_first_message_received"
"""会话被第一次交互时触发
kwargs:
session_name: str 会话名称(<launcher_type>_<launcher_id>)
session: pkg.openai.session.Session 会话对象
default_prompt: str 预设值
"""
SessionExplicitReset = "session_reset"
"""会话被用户手动重置时触发,此事件不支持阻止默认行为
kwargs:
session_name: str 会话名称(<launcher_type>_<launcher_id>)
session: pkg.openai.session.Session 会话对象
"""
SessionExpired = "session_expired"
"""会话过期时触发
kwargs:
session_name: str 会话名称(<launcher_type>_<launcher_id>)
session: pkg.openai.session.Session 会话对象
session_expire_time: int 已设置的会话过期时间(秒)
"""
KeyExceeded = "key_exceeded"
"""api-key超额时触发
kwargs:
key_name: str 超额的api-key名称
usage: dict 超额的api-key使用情况
exceeded_keys: list[str] 超额的api-key列表
"""
KeySwitched = "key_switched"
"""api-key超额切换成功时触发此事件不支持阻止默认行为
kwargs:
key_name: str 切换成功的api-key名称
key_list: list[str] api-key列表
"""
PromptPreProcessing = "prompt_pre_processing" # 于v2.5.1加入
"""每回合调用接口前对prompt进行预处理时触发此事件不支持阻止默认行为
kwargs:
session_name: str 会话名称(<launcher_type>_<launcher_id>)
default_prompt: list 此session使用的情景预设内容
prompt: list 此session现有的prompt内容
text_message: str 用户发送的消息文本
returns (optional):
default_prompt: list 修改后的情景预设内容
prompt: list 修改后的prompt内容
text_message: str 修改后的消息文本
"""
```
### host: PluginHost 详解
提供与主程序各个模块交互的一些方法,具体查看`pkg.plugin.host`中的`PluginHost`
### event: EventContext 详解
提供对此次事件执行的一些操作方法,具体查看`pkg.plugin.host`中的`EventContext`

View File

@@ -1,17 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
## 多个对话接口有何区别?
出于对稳定性的高要求本项目主线接入的是GPT-3模型接口此接口由OpenAI官方开放稳定性强。
目前支持通过加载[插件](https://github.com/RockChinQ/revLibs)的方式接入ChatGPT网页版使用的是acheong08/ChatGPT的逆向工程库但文本生成质量更高。
同时程序主线已支持ChatGPT API并作为默认接口 [#195](https://github.com/RockChinQ/QChatGPT/issues/195)
|官方接口|ChatGPT网页版|ChatGPT API
|---|---|---|
|官方开放,稳定性高 | 由[acheong08](https://github.com/acheong08)破解网页版协议接入| 由OpenAI官方开放
|一次性回复,响应速度较快| 流式回复,响应速度较慢|响应速度较快|
|收费0.02美元/千字|免费|收费0.002美元/千字|
|GPT-3模型|GPT-3.5模型|GPT-3.5模型|
|任何地区主机均可使用(疑似受到GFW影响)|ChatGPT限制访问的区域使用有难度|任何地区主机均可使用(疑似受到GFW影响)|

View File

@@ -1,73 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
# 配置go-cqhttp用于登录QQ
> 若您是从旧版本升级到此版本以使用go-cqhttp的用户请您按照`config-template.py`的内容修改`config.py`,添加`msg_source_adapter`配置项并将其设为`nakuru`,同时添加`nakuru_config`字段按照说明配置。
## 步骤
1. 从[go-cqhttp的Release](https://github.com/Mrs4s/go-cqhttp/releases/latest)下载最新的go-cqhttp可执行文件建议直接下载可执行文件压缩包而不是安装器
2. 解压并运行,首次运行会询问需要开放的网络协议,**请填入`02`并回车,必须输入`02`❗❗❗❗❗❗❗**
<h1> 你这里必须得输入`02`,你懂么,`0`必须得输入,看好了,看好下面输入什么了吗?别他妈的搁那就输个`2`完了启动连不上还跑群里问,问一个我踢一个。 </h1>
```
C:\Softwares\go-cqhttp.old> .\go-cqhttp.exe
未找到配置文件,正在为您生成配置文件中!
请选择你需要的通信方式:
> 0: HTTP通信
> 1: 云函数服务
> 2: 正向 Websocket 通信
> 3: 反向 Websocket 通信
请输入你需要的编号(0-9),可输入多个,同一编号也可输入多个(如: 233)
您的选择是:02
```
提示已生成`config.yml`文件关闭go-cqhttp。
3. 打开go-cqhttp同目录的`config.yml`
1. 编辑账号登录信息
只需要修改下方`uin``password`为你要登录的机器人账号的QQ号和密码即可。
**若您不填写,将会在启动时请求扫码登录。**
```yaml
account: # 账号相关
uin: 1233456 # QQ账号
password: '' # 密码为空时使用扫码登录
encrypt: false # 是否开启密码加密
status: 0 # 在线状态 请参考 https://docs.go-cqhttp.org/guide/config.html#在线状态
relogin: # 重连设置
delay: 3 # 首次重连延迟, 单位秒
interval: 3 # 重连间隔
max-times: 0 # 最大重连次数, 0为无限制
```
2. 修改websocket端口
在`config.yml`下方找到以下内容
```yaml
- ws:
# 正向WS服务器监听地址
address: 0.0.0.0:8080
middlewares:
<<: *default # 引用默认中间件
```
**将`0.0.0.0:8080`改为`0.0.0.0:6700`**,保存并关闭`config.yml`。
3. 若您的服务器位于公网,强烈建议您填写`access-token` (可选)
```yaml
# 默认中间件锚点
default-middlewares: &default
# 访问密钥, 强烈推荐在公网的服务器设置
access-token: ''
```
4. 配置完成重新启动go-cqhttp
> 若启动后登录不成功,请尝试根据[此文档](https://docs.go-cqhttp.org/guide/config.html#%E8%AE%BE%E5%A4%87%E4%BF%A1%E6%81%AF)修改`device.json`的协议编号。

View File

@@ -1,30 +0,0 @@
> [!WARNING]
> 此 Wiki 已弃用,所有文档已迁移到 [项目主页](https://qchatgpt.rockchin.top)
欢迎查看QChatGPT的Wiki页。
## 简介
调用OpenAI官方提供的API接口结合mirai和YiriMirai框架将QQ消息与语言模型连接实现更加智能的对话机器人
## 技术栈
- [Mirai](https://github.com/mamoe/mirai) 高效率 QQ 机器人支持库
- [YiriMirai](https://github.com/YiriMiraiProject/YiriMirai) 一个轻量级、低耦合的基于 mirai-api-http 的 Python SDK。
- [go-cqhttp](https://github.com/Mrs4s/go-cqhttp) cqhttp的golang实现轻量、原生跨平台.
- [nakuru-project](https://github.com/Lxns-Network/nakuru-project) - 一款为 go-cqhttp 的正向 WebSocket 设计的 Python SDK支持纯 CQ 码与消息链的转换处理
- [nakuru-project-idk](https://github.com/idoknow/nakuru-project-idk) - 由idoknow维护的nakuru-project分支
- [dulwich](https://github.com/jelmer/dulwich) Pure-Python Git implementation
- [OpenAI API](https://openai.com/api/) OpenAI API
## 代码结构
- `pkg.database` 数据库操作相关
- 数据库用于存放会话的历史记录,确保在程序重启后能记住对话内容
- `pkg.openai` OpenAI API相关
- 用于调用OpenAI的API生成回复内容
- `pkg.qqbot` QQ机器人相关
- 处理QQ收到的消息调用API并进行回复
- `pkg.utils` 常用功能包
- `pkg.audit` 审计模块
- `pkg.plugin` 插件管理相关功能

View File

@@ -1,13 +1,5 @@
{
"platform-adapters": [
{
"adapter": "yiri-mirai",
"enable": false,
"host": "127.0.0.1",
"port": 8080,
"verifyKey": "yirimirai",
"qq": 123456789
},
{
"adapter": "nakuru",
"enable": false,
@@ -37,7 +29,10 @@
"track-function-calls": true,
"quote-origin": false,
"at-sender": false,
"force-delay": [0, 0],
"force-delay": {
"min": 0,
"max": 0
},
"long-text-process": {
"threshold": 256,
"strategy": "forward",

View File

@@ -0,0 +1,39 @@
{
"type": "object",
"layout": "expansion-panels",
"properties": {
"command-prefix": {
"type": "array",
"title": "命令前缀",
"description": "以数组形式设置,程序将前缀符合设置的消息视为命令(群内需要符合群响应规则)",
"items": {
"type": "string"
},
"default": [
"!",
""
]
},
"privilege": {
"type": "object",
"title": "权限管理",
"description": "设置每个命令的权限配置。普通用户权限级别为 1管理员system.json中设置的权限级别为 2在这里设置每个命令的最低权限级别若设置为1则用户和管理员均可用若为2则仅管理员可用设置子命令时以点号间隔如\"plugin.on\"",
"properties": {
"placeholder": {
"type": "integer",
"minimum": 1,
"maximum": 2,
"const": 1
}
},
"patternProperties": {
"^[a-zA-Z0-9_.]+$": {
"type": "integer",
"minimum": 1,
"maximum": 2
}
},
"default": {}
}
}
}

View File

@@ -0,0 +1,326 @@
{
"type": "object",
"layout": "expansion-panels",
"properties": {
"access-control": {
"type": "object",
"title": "访问控制",
"properties": {
"mode": {
"type": "string",
"title": "访问控制模式",
"description": "访问控制模式,支持黑名单和白名单",
"enum": [
"blacklist",
"whitelist"
],
"default": "blacklist"
},
"blacklist": {
"type": "array",
"title": "黑名单",
"description": "黑名单中的会话将无法使用机器人,仅在访问控制模式为黑名单时有效。格式:{type}_{id}示例group_12345678 或 person_12341234",
"items": {
"type": "string",
"format": "regex",
"pattern": "^(person|group)_(\\d)*$"
},
"default": []
},
"whitelist": {
"type": "array",
"title": "白名单",
"description": "仅白名单中的会话可以使用机器人,仅在访问控制模式为白名单时有效。格式:{type}_{id}示例group_12345678 或 person_12341234",
"items": {
"type": "string",
"format": "regex",
"pattern": "^(person|group)_(\\d)*$"
},
"default": []
}
},
"required": [
"mode"
]
},
"respond-rules": {
"type": "object",
"title": "群消息响应规则",
"description": "仅处理 访问控制 允许的会话的消息。所有未指定的群使用 默认响应规则,若需指定特定的群的规则,请输入 群号 并添加,并设置响应规则",
"properties": {
"default": {
"type": "object",
"title": "默认响应规则",
"properties": {
"at": {
"type": "boolean",
"title": "是否响应 @ 消息",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"prefix": {
"type": "array",
"title": "响应前缀",
"description": "带有指定前缀的消息即使没有 at 机器人也会被响应,发送给 AI 时会删除前缀",
"items": {
"type": "string"
},
"default": []
},
"regexp": {
"type": "array",
"title": "响应正则表达式",
"description": "正则表达式教程https://www.runoob.com/regexp/regexp-syntax.html",
"items": {
"type": "string",
"format": "regex"
},
"default": []
},
"random": {
"type": "number",
"title": "随机响应概率",
"description": "数值范围是0.0-1.0对应概率0%-100%为1.0时所有消息都响应",
"minimum": 0,
"maximum": 1,
"step": 0.01,
"layout": {
"comp": "slider",
"props": {
"color": "primary"
}
}
}
}
}
},
"patternProperties": {
"^\\d+$": {
"type": "object",
"properties": {
"at": {
"type": "boolean",
"title": "是否响应 @ 消息",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"prefix": {
"type": "array",
"title": "响应前缀",
"description": "带有指定前缀的消息即使没有 at 机器人也会被响应,发送给 AI 时会删除前缀",
"items": {
"type": "string"
},
"default": []
},
"regexp": {
"type": "array",
"title": "响应正则表达式",
"description": "正则表达式教程https://www.runoob.com/regexp/regexp-syntax.html",
"items": {
"type": "string",
"format": "regex"
},
"default": []
},
"random": {
"type": "number",
"title": "随机响应概率",
"description": "数值范围是0.0-1.0对应概率0%-100%为1.0时所有消息都响应",
"minimum": 0,
"maximum": 1,
"step": 0.01,
"layout": {
"comp": "slider",
"props": {
"color": "primary"
}
}
}
}
}
}
},
"income-msg-check": {
"type": "boolean",
"title": "检查传入消息内容",
"description": "是否对传入的消息用户消息进行检查需配合审核策略使用AI 响应内容一定会通过检查策略)",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"ignore-rules": {
"type": "object",
"title": "传入消息忽略规则",
"description": "符合规则的传入消息将被忽略,仅传入消息检查被启用时生效",
"properties": {
"prefix": {
"type": "array",
"title": "忽略前缀",
"description": "具有指定前缀的消息将被忽略",
"items": {
"type": "string"
},
"default": []
},
"regexp": {
"type": "array",
"title": "忽略正则表达式",
"description": "正则表达式教程https://www.runoob.com/regexp/regexp-syntax.html",
"items": {
"type": "string",
"format": "regex"
},
"default": []
}
}
},
"check-sensitive-words": {
"type": "boolean",
"title": "本地敏感词检查",
"description": "是否启用本地敏感词检查",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"baidu-cloud-examine": {
"type": "object",
"title": "百度云内容审核配置",
"description": "百度云内容审核配置前往https://cloud.baidu.com/doc/ANTIPORN/index.html 获取 API Key 和 API Secret",
"properties": {
"enable": {
"type": "boolean",
"title": "是否启用",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"api-key": {
"type": "string",
"title": "API Key",
"default": ""
},
"api-secret": {
"type": "string",
"title": "API Secret",
"default": ""
}
}
},
"rate-limit": {
"type": "object",
"title": "请求限速规则",
"properties": {
"strategy": {
"type": "string",
"title": "限速策略",
"description": "会话中的请求速率超过限制时的处理策略drop为丢弃新请求wait为等待请求速率降到限制以下",
"enum": [
"drop",
"wait"
],
"default": "drop"
},
"algo": {
"type": "string",
"title": "限速算法",
"description": "目前仅支持 fixwin固定窗口支持插件扩展",
"enum": [
"fixwin"
],
"default": "fixwin"
},
"fixwin": {
"type": "object",
"title": "固定窗口限速策略配置",
"description": "所有会话使用默认限速策略,若需指定特定会话的限速策略,请输入 会话名称(格式为 {type}_{id}示例group_123456 或 person_123456 并添加,以设置特定会话的限速参数",
"properties": {
"default": {
"type": "object",
"title": "默认限速策略",
"properties": {
"window-size": {
"type": "integer",
"title": "窗口大小(秒)",
"minimum": 1,
"default": 60
},
"limit": {
"type": "integer",
"title": "窗口期间允许的最大消息数",
"minimum": 1,
"default": 60
}
}
}
},
"patternProperties": {
"^(person|group).*$": {
"type": "object",
"title": "会话限速",
"properties": {
"window-size": {
"type": "integer",
"title": "窗口大小(秒)",
"minimum": 1,
"default": 60
},
"limit": {
"type": "integer",
"title": "窗口期间允许的最大消息数",
"minimum": 1,
"default": 60
}
}
}
}
}
}
},
"msg-truncate": {
"type": "object",
"title": "对话历史记录截断",
"description": "将在发送消息给模型之前对当前会话的历史消息进行截断,以限制传给模型的消息长度",
"properties": {
"method": {
"type": "string",
"title": "截断方法",
"description": "目前仅支持 round按回合截断支持插件扩展",
"enum": [
"round"
],
"default": "round"
},
"round": {
"type": "object",
"title": "轮次截断策略配置",
"properties": {
"max-round": {
"type": "integer",
"title": "最大保留前文回合数",
"minimum": 1,
"default": 10
}
}
}
}
}
}
}

View File

@@ -0,0 +1,221 @@
{
"type": "object",
"layout": "expansion-panels",
"properties": {
"platform-adapters": {
"type": "array",
"title": "消息平台适配器",
"default": {},
"items": {
"type": "object",
"oneOf": [
{
"title": "Nakuru 适配器",
"description": "用于接入 go-cqhttp",
"properties": {
"adapter": {
"type": "string",
"const": "nakuru"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"host": {
"type": "string",
"default": "127.0.0.1"
},
"ws_port": {
"type": "integer",
"default": 8080
},
"http_port": {
"type": "integer",
"default": 5700
},
"token": {
"type": "string",
"default": ""
}
}
},
{
"title": "aiocqhttp 适配器",
"description": "用于接入 Lagrange 等兼容 OneBot v11 协议的机器人框架仅支持反向ws",
"properties": {
"adapter": {
"type": "string",
"const": "aiocqhttp"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"host": {
"type": "string",
"default": "0.0.0.0",
"description": "监听的 IP 地址,一般就保持 0.0.0.0 就可以了。使用 aiocqhttp 时LangBot 作为服务端被动等待框架连接,请在 Lagrange 等框架中设置被动 ws 地址或者反向 ws 地址(具体视框架而定)为 LangBot 监听的地址,且路径为/ws例如ws://127.0.0.1:8080/ws"
},
"port": {
"type": "integer",
"default": 8080,
"description": "设置监听的端口默认8080需在 Lagrange 等框架中设置为与此处一致的端口"
},
"access-token": {
"type": "string",
"default": "",
"description": "设置访问密钥,与 Lagrange 等框架中设置的保持一致"
}
}
},
{
"title": "qq-botpy 适配器",
"description": "用于接入 QQ 官方机器人 API",
"properties": {
"adapter": {
"type": "string",
"const": "qq-botpy"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"appid": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的appid"
},
"secret": {
"type": "string",
"default": "",
"description": "申请到的QQ官方机器人的secret"
},
"intents": {
"type": "array",
"description": "控制监听的事件类型需要填写才能接收到对应消息目前支持的事件类型有public_guild_messagesQQ 频道消息、direct_messageQQ 频道私聊消息、public_messagesQQ 群 和 列表私聊消息)",
"default": [
"public_guild_messages",
"direct_message",
"public_messages"
]
}
}
}
]
}
},
"track-function-calls": {
"type": "boolean",
"default": true,
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
},
"title": "跟踪内容函数调用",
"description": "开启之后,在对话中调用的内容函数记录也会发给用户,关闭后(false)仅会发给用户最终结果"
},
"quote-origin": {
"type": "boolean",
"default": false,
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
},
"title": "引用原消息",
"description": "在群内回复时是否引用原消息"
},
"at-sender": {
"type": "boolean",
"default": false,
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
},
"title": "是否 at 原用户",
"description": "在群内回复时是否@发送者"
},
"force-delay": {
"type": "object",
"default": {
"min": 0,
"max": 0
},
"title": "强制消息延迟范围",
"description": "在将响应内容发回给用户前的强制消息随机延迟时间范围,以防风控,单位是秒",
"properties": {
"min": {
"type": "integer",
"default": 0,
"description": "最小值,单位是秒"
},
"max": {
"type": "integer",
"default": 0,
"description": "最大值,单位是秒"
}
}
},
"long-text-process": {
"type": "object",
"title": "长消息处理策略",
"properties": {
"threshold": {
"type": "integer",
"default": 256,
"title": "长消息处理阈值",
"description": "当消息长度超过此阈值时,将启用长消息处理策略"
},
"strategy": {
"type": "string",
"default": "forward",
"title": "长消息处理策略",
"description": "长消息处理策略目前支持forward转发消息组件和image文字转图片。aiocqhttp 和 qq-botpy 不支持 forward 策略"
},
"font-path": {
"type": "string",
"description": "image的渲染字体。未设置时如果在windows下会尝试寻找系统的微软雅黑字体若找不到则转为forward策略。未设置时若不是windows系统则直接转为forward策略",
"default": ""
}
}
},
"hide-exception-info": {
"type": "boolean",
"default": true,
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
},
"title": "向用户隐藏AI接口的异常信息",
"description": "是否向用户隐藏AI的异常信息如果为true当请求AI接口出现异常时会返回一个错误的提示给用户。而把报错详情输出在控制台。"
}
}
}

View File

@@ -0,0 +1,207 @@
{
"type": "object",
"layout": "expansion-panels",
"properties": {
"enable-chat": {
"type": "boolean",
"default": true,
"title": "启用聊天功能",
"description": "是否启用 AI 聊天功能"
},
"enable-vision": {
"type": "boolean",
"default": true,
"title": "启用视觉功能",
"description": "是否开启AI视觉功能。需要使用的模型同时支持视觉功能详情见元数据板块"
},
"keys": {
"type": "object",
"title": "模型接口密钥",
"description": "以字典的形式设置若干个密钥组,每个密钥组的键为密钥组名称,值为密钥列表。模型与密钥组的对应关系,请查看元数据板块",
"properties": {
"openai": {
"type": "array",
"title": "OpenAI API 密钥",
"description": "OpenAI API 密钥",
"items": {
"type": "string"
},
"default": []
},
"anthropic": {
"type": "array",
"title": "Anthropic API 密钥",
"description": "Anthropic API 密钥",
"items": {
"type": "string"
},
"default": []
},
"moonshot": {
"type": "array",
"title": "Moonshot API 密钥",
"description": "Moonshot API 密钥",
"items": {
"type": "string"
},
"default": []
},
"deepseek": {
"type": "array",
"title": "DeepSeek API 密钥",
"description": "DeepSeek API 密钥",
"items": {
"type": "string"
},
"default": []
}
}
},
"requester": {
"type": "object",
"title": "大模型请求器",
"description": "以字典的形式设置若干个请求器,每个请求器的键为请求器名称,值为请求器配置。模型与请求器的对应关系,请查看元数据板块。实现请求器的方式,请查看插件编写教程",
"properties": {
"openai-chat-completions": {
"type": "object",
"title": "OpenAI API 请求配置",
"description": "仅可编辑 URL 和 超时时间,额外请求参数不支持可视化编辑,请到编辑器编辑",
"properties": {
"base-url": {
"type": "string",
"title": "API URL"
},
"args": {
"type": "object",
"default": {}
},
"timeout": {
"type": "number",
"title": "API 请求超时时间",
"default": 120
}
}
},
"anthropic-messages": {
"type": "object",
"title": "Anthropic API 请求配置",
"description": "仅可编辑 URL 和 超时时间,额外请求参数不支持可视化编辑,请到编辑器编辑",
"properties": {
"base-url": {
"type": "string",
"title": "API URL"
},
"args": {
"type": "object",
"default": {}
},
"timeout": {
"type": "number",
"title": "API 请求超时时间",
"default": 120
}
}
},
"moonshot-chat-completions": {
"type": "object",
"title": "Moonshot API 请求配置",
"description": "仅可编辑 URL 和 超时时间,额外请求参数不支持可视化编辑,请到编辑器编辑",
"properties": {
"base-url": {
"type": "string",
"title": "API URL"
},
"args": {
"type": "object",
"default": {}
},
"timeout": {
"type": "number",
"title": "API 请求超时时间",
"default": 120
}
}
},
"deepseek-chat-completions": {
"type": "object",
"title": "DeepSeek API 请求配置",
"description": "仅可编辑 URL 和 超时时间,额外请求参数不支持可视化编辑,请到编辑器编辑",
"properties": {
"base-url": {
"type": "string",
"title": "API URL"
},
"args": {
"type": "object",
"default": {}
},
"timeout": {
"type": "number",
"title": "API 请求超时时间",
"default": 120
}
}
},
"ollama-chat": {
"type": "object",
"title": "Ollama API 请求配置",
"description": "仅可编辑 URL 和 超时时间,额外请求参数不支持可视化编辑,请到编辑器编辑",
"properties": {
"base-url": {
"type": "string",
"title": "API URL"
},
"args": {
"type": "object"
},
"timeout": {
"type": "number",
"title": "API 请求超时时间",
"default": 600
}
}
}
}
},
"model": {
"type": "string",
"title": "所使用的模型名称",
"description": "设置要使用的模型名称。通常来说直接填写模型名称即可,但如果要使用原生接口不是 ChatCompletion 但以 ChatCompletion 接口格式接入的模型,请在模型名称前方加一个 OneAPI/ 前缀以进行区分。 简单来说可以认为是:现阶段非 OpenAI 的模型接入都需要在模型名称前方加一个 OneAPI/ 前缀。\n\n例如\n\n1. 通过 OneAPI 等中转服务接入了 OpenAI 的 gpt-4 模型,由于 gpt-4 也是使用 ChatCompletion 接口格式进行请求,则可以直接填入 gpt-4\n2. 通过 OneAPI 等中转服务接入了 Google 的 gemini-pro 模型,由于 gemini-pro 原生接口格式并非 ChatCompletion因此需要填入 OneAPI/gemini-pro。\n具体支持的模型列表和各个模型对应的请求器和密钥组请查看元数据板块 llm-models.json "
},
"prompt-mode": {
"type": "string",
"title": "情景预设(人格)模式",
"description": "值为normal单预设模式和full-scenario完整历史对话模式normal模式时使用下方设置的情景预设也支持读取data/prompts目录下的文件内容作为单个 System Prompt文件名即为prompt的名称full-scenario模式时读取 data/scenario/ 下的完整历史对话作为情景预设",
"enum": ["normal", "full-scenario"],
"default": "normal"
},
"prompt": {
"type": "object",
"title": "情景预设(人格)",
"description": "设置情景预设人格。值为空字符串时将不使用情景预设人格。normal模式时使用下方设置的情景预设也支持读取data/prompts目录下的文件内容作为单个 System Prompt文件名即为prompt的名称full-scenario模式时读取 data/scenario/ 下的完整历史对话作为情景预设",
"properties": {
"default": {
"type": "string",
"title": "默认情景预设",
"description": "设置默认情景预设。值为空字符串时,将不使用情景预设(人格)",
"default": ""
}
},
"patternProperties": {
"^.*$": {
"type": "string",
"title": "情景预设",
"description": "设置情景预设。值为空字符串时,将不使用情景预设(人格)",
"default": ""
}
},
"required": ["default"]
},
"runner": {
"type": "string",
"title": "请求运行器",
"description": "设置请求运行器。值为local-agent时使用内置默认运行器支持插件扩展",
"default": "local-agent"
}
}
}

View File

@@ -0,0 +1,121 @@
{
"type": "object",
"layout": "expansion-panels",
"properties": {
"admin-sessions": {
"type": "array",
"title": "管理员会话",
"description": "设置管理员会话,格式为 {type}_{id}type 为 \"group\" 或 \"person\"group_123456 或 person_123456",
"items": {
"type": "string",
"format": "regex",
"pattern": "^(person|group)_(\\d+)$"
},
"default": []
},
"network-proxies": {
"type": "object",
"title": "网络代理",
"description": "正向代理http和https都要填例如http://127.0.0.1:7890 https://127.0.0.1:7890 。不使用代理请留空。正向代理也可以用环境变量设置http_proxy 和 https_proxy",
"properties": {
"http": {
"type": "string"
},
"https": {
"type": "string"
}
}
},
"report-usage": {
"type": "boolean",
"title": "上报遥测数据",
"description": "遥测数据用于统计和分析项目使用情况,不包含任何隐私信息,不建议禁用",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
}
},
"logging-level": {
"type": "string",
"title": "日志等级",
"description": "目前无效启用调试模式请设置环境变量export DEBUG=true"
},
"session-concurrency": {
"type": "object",
"title": "会话消息处理并发数",
"description": "粒度是单个会话,所有会话使用默认并发数,若需指定特定会话的并发数,请输入 会话名称(格式为 {type}_{id}示例group_123456 或 person_123456 并添加,以设置特定会话的并发数",
"properties": {
"default": {
"type": "integer"
}
},
"patternProperties": {
"^(person|group)_(\\d+)$": {
"type": "integer"
}
}
},
"pipeline-concurrency": {
"type": "integer",
"title": "流水线消息处理并发数",
"description": "粒度是整个程序,目前使用 FCFS 算法调度各个请求"
},
"qcg-center-url": {
"type": "string",
"title": "遥测服务器地址",
"description": "运行期间推送遥测数据的目标地址,默认为官方地址,若您自己部署了 https://github.com/RockChinQ/qcg-center可以改为你的地址。"
},
"help-message": {
"type": "string",
"title": "帮助消息",
"description": "用户发送 !help 命令时的输出",
"layout": "textarea"
},
"http-api": {
"type": "object",
"title": "HTTP 接口",
"properties": {
"enable": {
"type": "boolean",
"layout": {
"comp": "switch",
"props": {
"color": "primary"
}
},
"title": "是否启用"
},
"host": {
"type": "string"
},
"port": {
"type": "integer"
}
}
},
"persistence": {
"type": "object",
"title": "持久化设置",
"properties": {
"sqlite": {
"type": "object",
"title": "sqlite",
"properties": {
"path": {
"type": "string"
}
}
},
"use": {
"type": "string",
"title": "所使用的数据库",
"enum": [
"sqlite"
]
}
}
}
}
}

View File

@@ -11,5 +11,16 @@
},
"pipeline-concurrency": 20,
"qcg-center-url": "https://api.qchatgpt.rockchin.top/api/v2",
"help-message": "QChatGPT - 😎高稳定性、🧩支持插件、🌏实时联网的 ChatGPT QQ 机器人🤖\n链接https://q.rkcn.top"
"help-message": "LangBot - 😎高稳定性、🧩支持插件、🌏实时联网的 ChatGPT QQ 机器人🤖\n链接https://q.rkcn.top",
"http-api": {
"enable": true,
"host": "0.0.0.0",
"port": 5300
},
"persistence": {
"sqlite": {
"path": "data/persistence.db"
},
"use": "sqlite"
}
}

4
web/.browserslistrc Normal file
View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
web/.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

10
web/.eslintrc.js Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
],
}

22
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
web/README.md Normal file
View File

@@ -0,0 +1 @@
# WebUI

16
web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangBot 面板</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

20
web/jsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"allowJs": true,
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "bundler",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

6363
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
},
"dependencies": {
"@koumoul/vjsf": "^3.0.0-beta.46",
"@mdi/font": "7.4.47",
"ajv": "^8.17.1",
"ajv-dist": "^8.17.1",
"ajv-errors": "^3.0.0",
"ajv-formats": "^3.0.1",
"ajv-i18n": "^4.2.0",
"ansi_up": "^6.0.2",
"axios": "^1.7.7",
"codemirror": "^5.65.18",
"core-js": "^3.37.1",
"json-editor-vue": "^0.17.3",
"roboto-fontface": "*",
"vue": "^3.4.31",
"vuedraggable": "^4.1.0",
"vuetify": "^3.6.11",
"vuex": "^4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.4.0",
"eslint-plugin-vue": "^9.27.0",
"sass": "1.77.6",
"unplugin-fonts": "^1.1.1",
"unplugin-vue-components": "^0.27.2",
"unplugin-vue-router": "^0.10.0",
"vite": "^5.3.3",
"vite-plugin-vuetify": "^2.0.3",
"vue-router": "^4.4.0"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Some files were not shown because too many files have changed in this diff Show More