diff --git a/.env b/.env index 34e2b1c..47c54c5 100644 --- a/.env +++ b/.env @@ -18,7 +18,6 @@ PLUGINS='[ "nonebot_plugin_shorturl", "nonebot_bison", "nonebot_plugin_treehelp", - "nonebot_plugin_deepseek", "nonebot_plugin_llmchat", "nonebot_plugin_zssm" ]' @@ -85,6 +84,8 @@ SHORTURL_HOST SHORTURL_ENDPOINT ## deepseek config +DEEPSEEK__ENABLE_GROUP_CONTEXT=true +DEEPSEEK__MAX_GROUP_HISTORY=20 DEEPSEEK__TIMEOUT=600 ## zssm config diff --git a/pyproject.toml b/pyproject.toml index a851f52..47de50e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,9 @@ dependencies = [ "nonebot-bison >=0.9.0, <1.0.0", "nonebot-plugin-alconna >=0.38.0, <1.0.0", "nonebot-plugin-apscheduler >=0.4.0, <1.0.0", - "nonebot-plugin-deepseek >=0.1.0, <1.0.0", + "nonebot-plugin-localstore >=0.7.0, <1.0.0", + "nonebot-plugin-waiter >=0.2.0, <1.0.0", + "beautifulsoup4 >=4.12.3, <5.0.0", "nonebot-plugin-filehost >=1.1.0, <2.0.0", "nonebot-plugin-llmchat >=0.1.0, <1.0.0", "nonebot-plugin-sentry >=2.0.0, <3.0.0", @@ -33,6 +35,7 @@ deploy = ["gunicorn >=23.0.0, <24.0.0", "uvicorn >=0.18.0, <1.0.0"] [tool.ruff] line-length = 88 target-version = "py312" +exclude = ["src/plugins/nonebot_plugin_deepseek"] [tool.ruff.format] line-ending = "lf" @@ -72,6 +75,7 @@ keep-runtime-typing = true [tool.pyright] pythonPlatform = "All" +exclude = ["src/plugins/nonebot_plugin_deepseek"] typeCheckingMode = "standard" reportShadowedImports = false diff --git a/src/plugins/nonebot_plugin_deepseek/__init__.py b/src/plugins/nonebot_plugin_deepseek/__init__.py new file mode 100644 index 0000000..acedc87 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/__init__.py @@ -0,0 +1,370 @@ +import itertools +from pathlib import Path +from typing import Optional +from importlib import import_module +from importlib.util import find_spec + +from nonebot import require +from nonebot.adapters import Event +from nonebot.params import Depends +from nonebot.permission import SuperUser +from nonebot.plugin import PluginMetadata, inherit_supported_adapters + +require("nonebot_plugin_waiter") +require("nonebot_plugin_alconna") +require("nonebot_plugin_localstore") +from arclet.alconna import config as alc_config +from nonebot_plugin_alconna.builtins.extensions.reply import ReplyMergeExtension +from nonebot_plugin_alconna import ( + Args, + Field, + Match, + Query, + Option, + Alconna, + MultiVar, + Namespace, + Subcommand, + UniMessage, + CommandMeta, + on_alconna, +) + +from .config import Config, ds_config, tts_config, json_config + +if find_spec("nonebot_plugin_htmlrender"): + require("nonebot_plugin_htmlrender") + htmlrender_enable = True + text_to_pic = import_module("nonebot_plugin_htmlrender").text_to_pic + +else: + htmlrender_enable = False + +from .apis import API +from . import hook as hook +from .version import __version__ +from .utils import DeepSeekHandler +from .exception import RequestException +from .extension import ParseExtension, CleanDocExtension +from .group_context import ( + get_group_session_id, + clear_context as clear_group_context, +) + +__plugin_meta__ = PluginMetadata( + name="DeepSeek", + description="接入 DeepSeek 模型,提供智能对话与问答功能", + usage="/deepseek -h", + type="application", + config=Config, + homepage="https://github.com/KomoriDev/nonebot-plugin-deepseek", + supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"), + extra={ + "unique_name": "DeepSeek", + "author": "Komorebi ", + "version": __version__, + }, +) + + +ns = Namespace("deepseek", disable_builtin_options=set()) +alc_config.namespaces["deepseek"] = ns + +deepseek = on_alconna( + Alconna( + "deepseek", + Args["content?#内容", MultiVar("str")], + Option( + "--use-model", + Args[ + "model#模型名称", + ds_config.get_enable_models(), + Field(completion=lambda: f"请输入模型名,预期为:{ds_config.get_enable_models()} 其中之一"), + ], + help_text="指定模型", + ), + Option("--with-context", help_text="启用多轮对话"), + Option("--with-group-context", help_text="启用群聊共享上下文"), + Option("--no-context", help_text="禁用上下文(单轮对话)"), + Option("--reset-group-context", help_text="重置当前群聊上下文"), + Option("-r|--render|--render-markdown", dest="render", help_text="渲染 Markdown 为图片"), + Option("--use-tts", help_text="使用 TTS 回复"), + Subcommand("--balance", help_text="查看余额"), + Subcommand( + "model", + Option("-l|--list", help_text="支持的模型列表"), + Option( + "--set-default", + Args[ + "model#模型名称", + ds_config.get_enable_models(), + Field(completion=lambda: f"请输入模型名,预期为:{ds_config.get_enable_models()} 其中之一"), + ], + dest="set", + help_text="设置默认模型", + ), + Option( + "--render-markdown", + Args[ + "state#状态", + ["enable", "disable", "on", "off"], + Field(completion=lambda: '请输入状态,预期为:["enable", "disable", "on", "off"] 其中之一'), + ], + help_text="启用 Markdown 转图片", + ), + help_text="模型相关设置", + ), + Subcommand( + "tts", + Option("-l|--list", Args["page?#页码", int], help_text="支持的 TTS 模型列表"), + Option( + "--set-default", + Args[ + "model#模型名称", + str, + Field( + completion=lambda: f"请输入 TTS 模型预设名,预期为:" + f"{list(json_config.available_tts_models.keys())[:10]}…… 其中之一\n" + "输入 `/deepseek tts -l` 查看所有 TTS 模型及角色" + ), + ], + dest="set", + help_text="设置默认 TTS 模型", + ), + help_text="TTS 模型相关设置", + ), + namespace=alc_config.namespaces["deepseek"], + meta=CommandMeta( + description=__plugin_meta__.description, + usage=__plugin_meta__.usage, + ), + ), + aliases={"ds"}, + use_cmd_start=True, + skip_for_unmatch=False, + comp_config={"lite": True}, + extensions=[ReplyMergeExtension, CleanDocExtension, ParseExtension], +) + +deepseek.shortcut("多轮对话", {"command": "deepseek --with-context", "fuzzy": True, "prefix": True}) +deepseek.shortcut("深度思考", {"command": "deepseek --use-model deepseek-reasoner", "fuzzy": True, "prefix": True}) +deepseek.shortcut("余额", {"command": "deepseek --balance", "fuzzy": False, "prefix": True}) +deepseek.shortcut("模型列表", {"command": "deepseek model --list", "fuzzy": False, "prefix": True}) +deepseek.shortcut("设置默认模型", {"command": "deepseek model --set-default", "fuzzy": True, "prefix": True}) +deepseek.shortcut("TTS模型列表", {"command": "deepseek tts --list", "fuzzy": False, "prefix": True}) +deepseek.shortcut("设置默认TTS模型", {"command": "deepseek tts --set-default", "fuzzy": True, "prefix": True}) +deepseek.shortcut("多轮语音对话", {"command": "deepseek --use-tts --with-context", "fuzzy": True, "prefix": True}) + + +@deepseek.assign("balance") +async def _(is_superuser: bool = Depends(SuperUser())): + if not is_superuser: + await deepseek.finish("该指令仅超管可用") + try: + balances = await API.query_balance(json_config.default_model) + + await deepseek.finish( + "".join( + f""" + 货币:{balance.currency} + 总的可用余额: {balance.total_balance} + 未过期的赠金余额: {balance.granted_balance} + 充值余额: {balance.topped_up_balance} + """ + for balance in balances.balance_infos + ) + ) + except ValueError as e: + await deepseek.finish(str(e)) + except RequestException as e: + await deepseek.finish(str(e)) + + +@deepseek.assign("model.list") +async def _(): + model_list = "\n".join( + f"- {model}(默认)" if model == json_config.default_model else f"- {model}" + for model in ds_config.get_enable_models() + ) + message = ( + f"支持的模型列表: \n{model_list}\n" + "输入 `/deepseek [内容] --use-model [模型名]` 单次选择模型\n" + "输入 `/deepseek model --set-default [模型名]` 设置默认模型" + ) + await deepseek.finish(message) + + +@deepseek.assign("model.set") +async def _( + is_superuser: bool = Depends(SuperUser()), + model: Query[str] = Query("model.set.model"), +): + if not is_superuser: + await deepseek.finish("该指令仅超管可用") + json_config.default_model = model.result + json_config.save() + await deepseek.finish(f"已设置默认模型为:{model.result}") + + +@deepseek.assign("model.render-markdown") +async def _( + is_superuser: bool = Depends(SuperUser()), + state: Query[str] = Query("model.render-markdown.state"), +): + if not is_superuser: + await deepseek.finish("该指令仅超管可用") + if not htmlrender_enable: + await deepseek.finish("Markdown 转图片功能暂不可用") + + if state.result == "enable" or state.result == "on": + state_desc = "开启" + json_config.enable_md_to_pic = True + else: + state_desc = "关闭" + json_config.enable_md_to_pic = False + + json_config.save() + await deepseek.finish(f"已{state_desc} Markdown 转图片功能") + + +@deepseek.assign("tts.list") +async def _( + page: Query[int] = Query("tts.list.page"), +): + if not tts_config.enable_models: + await deepseek.finish("当前未启用 TTS 功能") + + def parse_model_dict(model_dict: dict[str, dict[str, list[str]]], start_index: int) -> str: + return "\n".join( + (f"{'✅️ ' if model_name == default_model.model_name else '⏹️'}{start_index + index + 1}.{model_name}") + for index, model_name in enumerate(model_dict.keys()) + if json_config.default_tts_model + and (default_model := tts_config.get_tts_model(json_config.default_tts_model)) + ) + + if json_config.available_tts_models: + page_size = 200 + page_num = page.result if page.available else 1 + start_index = (page_num - 1) * page_size + page_model_dict = dict( + itertools.islice(json_config.available_tts_models.items(), start_index, start_index + page_size) + ) + if not page_model_dict: + await deepseek.finish(f"页码 {page_num} 超出范围,没有找到任何模型。") + + model_list_msg = parse_model_dict(page_model_dict, start_index) + custom_models = ( + "\n".join( + f"{'✅️ ' if model.name == json_config.default_tts_model else '⏹️'}{index + 1}.{model.name}" + for index, model in enumerate(tts_config.enable_models) + ) + if isinstance(tts_config.enable_models, list) + else "" + ) + else: + await deepseek.finish("当前未查找到可用模型") + + total_models = len(json_config.available_tts_models) + total_pages = (total_models + page_size - 1) // page_size + + if page_num > total_pages or page_num < 1: + await deepseek.finish("请输入正确的页码") + + header_msg = ( + f"支持的 TTS 模型列表 \n(第 {page_num}/{total_pages} 页, 共 {total_models} 个):\n\n" + f"当前TTS模型:\n✅️ {json_config.default_tts_model}\n\n" + ) + message = ( + (f"自定义 TTS 模型预设:\n {custom_models}" if isinstance(tts_config.enable_models, list) else "") + + f"\n\n{header_msg}" + + model_list_msg + ) + if htmlrender_enable: + custom_models_html = "".join(f"
{line}
" for line in custom_models.split("\n") if line) + header_html = ( + f"
" + f"

自定义 TTS 预设

" + f"
{custom_models_html}
" + ) + model_lines = "".join(f"
{line}
" for line in model_list_msg.split("\n") if line) + model_html = f"

{header_msg}

{model_lines}
" + final_html = header_html + model_html + css_path = str(Path(__file__).parent / "resources/tts_models.css") + await deepseek.finish(UniMessage.image(raw=await text_to_pic(text=final_html, css_path=css_path, width=1440))) + await deepseek.finish(message) + + +@deepseek.assign("tts.set") +async def _( + is_superuser: bool = Depends(SuperUser()), + model: Query[str] = Query("tts.set.model"), +): + if not tts_config.enable_models: + await deepseek.finish("当前未启用 TTS 功能") + if not is_superuser: + await deepseek.finish("该指令仅超管可用") + available_tts_model_names = list(json_config.available_tts_models.keys()) + tts_config.get_enable_tts() + if model.result not in available_tts_model_names: + await deepseek.finish( + f"请输入 TTS 模型预设名,预期为:" + f"{list(json_config.available_tts_models.keys())[:10]}…… 其中之一\n" + "输入 `/deepseek tts -l` 查看所有 TTS 模型及角色" + ) + json_config.default_tts_model = model.result + json_config.save() + await deepseek.finish(f"已设置默认 TTS 模型为:{model.result}") + + +@deepseek.handle() +async def _( + event: Event, + content: Match[tuple[str, ...]], + model_name: Query[str] = Query("use-model.model"), + use_tts: Query[bool] = Query("use-tts.value"), + render_option: Query[bool] = Query("render.value"), + context_option: Query[bool] = Query("with-context.value"), + with_group_context_option: Query[bool] = Query("with-group-context.value"), + no_context_option: Query[bool] = Query("no-context.value"), + reset_group_context_option: Query[bool] = Query("reset-group-context.value"), +) -> None: + if reset_group_context_option.available: + session_id = await get_group_session_id(event) + if session_id: + clear_group_context(session_id) + await deepseek.finish("已重置当前群聊上下文") + else: + await deepseek.finish("当前会话不支持群聊上下文重置") + + tts_model = None + if not model_name.available: + model_name.result = json_config.default_model + if use_tts.available and tts_config.enable_models and isinstance(json_config.default_tts_model, str): + tts_model = tts_config.get_tts_model(json_config.default_tts_model) + + model = ds_config.get_model_config(model_name.result) + if not render_option.available: + render_option.result = json_config.enable_md_to_pic + + render_option.result = render_option.result if htmlrender_enable else False + + # Determine whether to use group-shared context + is_group_context = False + group_session_id: Optional[str] = None + if not context_option.available: # multi-round mode takes precedence + if no_context_option.available: + is_group_context = False + elif with_group_context_option.available: + group_session_id = await get_group_session_id(event) + is_group_context = group_session_id is not None + elif ds_config.enable_group_context: + group_session_id = await get_group_session_id(event) + is_group_context = group_session_id is not None + + await DeepSeekHandler( + model=model, + is_to_pic=render_option.result, + is_contextual=context_option.available, + is_group_context=is_group_context, + group_session_id=group_session_id, + tts_model=tts_model if use_tts.available and tts_config.enable_models else None, + ).handle(" ".join(content.result) if content.available else None) diff --git a/src/plugins/nonebot_plugin_deepseek/_types.py b/src/plugins/nonebot_plugin_deepseek/_types.py new file mode 100644 index 0000000..b5cec64 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/_types.py @@ -0,0 +1,38 @@ +""" +@author: openai +@website: https://github.com/openai/openai-python/blob/main/src/openai/_types.py +""" + +from typing_extensions import override +from typing import Union, Literal, TypeVar + +_T = TypeVar("_T") + + +class NotGiven: + """ + A sentinel singleton class used to distinguish omitted keyword arguments + from those passed in with the value None (which may have different behavior). + + For example: + + ```py + def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + + + get(timeout=1) # 1s timeout + get(timeout=None) # No timeout + get() # Default timeout behavior, which may not be statically known at the method definition. + ``` + """ + + def __bool__(self) -> Literal[False]: + return False + + @override + def __repr__(self) -> str: + return "NOT_GIVEN" + + +NotGivenOr = Union[_T, NotGiven] +NOT_GIVEN = NotGiven() diff --git a/src/plugins/nonebot_plugin_deepseek/apis/__init__.py b/src/plugins/nonebot_plugin_deepseek/apis/__init__.py new file mode 100644 index 0000000..10ccca9 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/apis/__init__.py @@ -0,0 +1 @@ +from .request import API as API diff --git a/src/plugins/nonebot_plugin_deepseek/apis/request.py b/src/plugins/nonebot_plugin_deepseek/apis/request.py new file mode 100644 index 0000000..7d9b4f6 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/apis/request.py @@ -0,0 +1,195 @@ +from json import loads +from typing import Union, Literal, Optional + +import httpx + +from ..compat import model_dump + +# from ..function_call import registry +from ..log import ds_logger, tts_logger +from ..exception import RequestException +from ..config import ds_config, tts_config, json_config, uninfo_enable +from ..schemas import Balance, TTSModelInfo, ChatCompletions, StreamChoiceList + + +class API: + _headers = { + "Accept": "application/json", + } + + @classmethod + async def chat(cls, message: list[dict[str, str]], model: str = "deepseek-chat") -> ChatCompletions: + """普通对话""" + model_config = ds_config.get_model_config(model) + + api_key = model_config.api_key or ds_config.api_key + prompt: str = model_dump(model_config, exclude_none=True).get("prompt", ds_config.prompt) + if uninfo_enable: + if json_config.prompt_func is None: + json_config.set_prompt_func(prompt) + else: + prompt = await json_config.get_prompt() + proxy = model_config.proxy + + json = { + "messages": ([{"content": prompt, "role": "system"}] + message if prompt else message), + "model": model, + **model_config.to_dict(), + } + ds_logger( + "DEBUG", f"使用模型 {f'{model} ({model_config.alias})' if model_config.alias else model},配置:{json}" + ) + # if model == "deepseek-chat": + # json.update({"tools": registry.to_json()}) + if model_dump(model_config, exclude_none=True).get("stream", ds_config.stream): + ret = await stream_request(model_config.base_url, api_key, json, proxy) + else: + ret = await common_request(model_config.base_url, api_key, json, proxy) + + return ret + + @classmethod + async def query_balance(cls, model_name: str) -> Balance: + model_config = ds_config.get_model_config(model_name) + api_key = model_config.api_key or ds_config.api_key + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{model_config.base_url}/user/balance", + headers={**cls._headers, "Authorization": f"Bearer {api_key}"}, + ) + if response.status_code == 404: + raise RequestException("本地模型不支持查询余额,请更换默认模型") + return Balance(**response.json()) + + @classmethod + async def get_tts_models(cls) -> list[TTSModelInfo]: + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{tts_config.base_url}/models", + headers={**cls._headers}, + json={"version": tts_config.tts_version}, + timeout=tts_config.timeout, + ) + if response.status_code != 200: + raise RequestException(f"获取 TTS 模型列表失败,状态码: {response.status_code}") + return [ + TTSModelInfo(model_name=key, language_emotions=value) + for key, value in response.json().get("models", {}).items() + if isinstance(value, dict) + ] + except httpx.ConnectError as e: + raise RequestException(f"连接 TTS 模型服务器失败: {e}") + + @classmethod + async def text_to_speach(cls, text: str, model: str) -> bytes: + model_config = tts_config.get_tts_model(model) + model_name = model_config.model_name + json = { + "text": text, + "model_name": model_name, + "app_key": tts_config.access_token, + "access_token": tts_config.access_token, + "version": tts_config.tts_version, + "dl_url": tts_config.dl_url, + **model_config.to_dict(), + } + + tts_logger("DEBUG", f"使用模型 {model},配置:{json}") + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{tts_config.base_url}/infer_single", + headers={**cls._headers, "Authorization": f"Bearer {tts_config.access_token}"}, + json=json, + timeout=tts_config.timeout, + ) + tts_logger("DEBUG", f"Response: {response.status_code} {response.text}") + if audio_url := response.json().get("audio_url"): + async with httpx.AsyncClient() as client: + response = await client.get(audio_url, timeout=tts_config.timeout) + return response.content + else: + raise RequestException("语音合成失败") + except (httpx.ConnectError, httpx.ReadTimeout) as e: + raise RequestException(f"连接 TTS 服务器失败: {e}") + + +async def common_request(base_url: str, api_key: str, json: dict, proxy: Optional[str] = None): + timeout_config = ds_config.timeout + async with httpx.AsyncClient(proxy=proxy) as client: + response = await client.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=json, + timeout=(timeout_config if isinstance(timeout_config, int) else timeout_config.api_request), + ) + if error := response.json().get("error"): + raise RequestException(error["message"]) + return ChatCompletions(**response.json()) + + +async def stream_request(base_url: str, api_key: str, json: dict, proxy: Optional[str] = None): + json["stream"] = True + async with httpx.AsyncClient(http2=True, proxy=proxy, timeout=None) as client: + async with client.stream( + "POST", + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=json, + ) as response: + ret_list: Optional[StreamChoiceList] = None + async for chunk in response.aiter_lines(): + ret = sse_middle(chunk) + if ret is None: + continue + elif ret[0] == "data": + if ret[1] == "[DONE]": + break + else: + try: + i = loads(ret[1]) + if ret_list is None: + ret_list = StreamChoiceList(**i) + else: + ret_list += StreamChoiceList(**i) + except Exception as e: + ds_logger("ERROR", f"解析数据块失败:{ret[1]} ||{e}") + + elif ret[0] == "::": + ds_logger("ERROR", f"SSE注释:{ret[1]}") + continue + elif ret[0] == "error": + raise RequestException(ret[1]) + else: + continue + if ret_list is None: + raise RequestException("Oops! 网络超时,请稍后重试") + return ret_list.transform() + + +def sse_middle( + line: str, +) -> Union[tuple[Literal["data", "event", "id", "retry", "::", "error"], str], None]: + """单行SSE数据解析""" + line = line.strip("\r") + if not line: + return None + if ":" in line: + field, value = line.split(":", 1) + value = value.strip() + else: + return None + if field == "": + return "::", value + elif field == "data" or field == "event" or field == "id" or field == "retry": + return field, value + + return "error", line diff --git a/src/plugins/nonebot_plugin_deepseek/cli/__init__.py b/src/plugins/nonebot_plugin_deepseek/cli/__init__.py new file mode 100644 index 0000000..a1dbca2 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/cli/__init__.py @@ -0,0 +1,15 @@ +from clilte import CommandLine +from arclet.alconna import Argv, set_default_argv_type + +from ..version import __version__ +from .plugins.tts import TTSUpdate + +set_default_argv_type(Argv) +deepseek = CommandLine( + title="NB CLI plugin for nonebot-plugin-deepseek", + version=__version__, + rich=True, + _name="nb deepseek", + load_preset=True, +) +deepseek.add(TTSUpdate) diff --git a/src/plugins/nonebot_plugin_deepseek/cli/__main__.py b/src/plugins/nonebot_plugin_deepseek/cli/__main__.py new file mode 100644 index 0000000..34f782a --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/cli/__main__.py @@ -0,0 +1,7 @@ +import sys + + +def main(*args): + from ..cli import deepseek + + deepseek.main(*(["nb deepseek"] + list(args or sys.argv[1:]))) diff --git a/src/plugins/nonebot_plugin_deepseek/cli/plugins/__init__.py b/src/plugins/nonebot_plugin_deepseek/cli/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/plugins/nonebot_plugin_deepseek/cli/plugins/tts.py b/src/plugins/nonebot_plugin_deepseek/cli/plugins/tts.py new file mode 100644 index 0000000..ed975db --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/cli/plugins/tts.py @@ -0,0 +1,45 @@ +import asyncio +from typing import Union + +from clilte import BasePlugin, PluginMetadata +from arclet.alconna.tools import RichConsoleFormatter +from arclet.alconna import ( + Option, + Alconna, + Arparma, + Subcommand, + CommandMeta, +) + +from ...log import tts_logger +from ...config import tts_config, json_config + + +class TTSUpdate(BasePlugin): + def init(self) -> Union[Alconna, str]: + return Alconna( + "tts", + Subcommand("update", help_text="更新 TTS 模型列表"), + meta=CommandMeta("DeepSeek TTS 相关指令"), + formatter_type=RichConsoleFormatter, + ) + + def meta(self) -> PluginMetadata: + return PluginMetadata("TTSUpdate", "0.0.1", "更新 TTS 模型配置缓存", ["tts"], ["FrostN0v0"]) + + def dispatch(self, result: Arparma) -> Union[bool, None]: + if result.find("tts.update"): + available_models = asyncio.run(tts_config.get_available_tts()) + if available_models: + json_config.available_tts_models = available_models + json_config.save() + tts_logger("SUCCESS", f"Update available TTS models: {available_models}") + return + if result.find("tts"): + tts_logger("INFO", f"\n{self.command.get_help()}") + return + return True + + @classmethod + def supply_options(cls) -> Union[list[Option], None]: + return diff --git a/src/plugins/nonebot_plugin_deepseek/compat.py b/src/plugins/nonebot_plugin_deepseek/compat.py new file mode 100644 index 0000000..a1e8e06 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/compat.py @@ -0,0 +1,24 @@ +from typing import Literal, overload + +from nonebot.compat import PYDANTIC_V2 +from nonebot.compat import model_dump as model_dump + +__all__ = ( + "model_dump", + "model_validator", +) + + +if PYDANTIC_V2: + from pydantic import model_validator as model_validator +else: + from pydantic import root_validator + + @overload + def model_validator(*, mode: Literal["before"]): ... + + @overload + def model_validator(*, mode: Literal["after"]): ... + + def model_validator(*, mode: Literal["before", "after"]): + return root_validator(pre=mode == "before", allow_reuse=True) # type: ignore diff --git a/src/plugins/nonebot_plugin_deepseek/config.py b/src/plugins/nonebot_plugin_deepseek/config.py new file mode 100644 index 0000000..756cf9c --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/config.py @@ -0,0 +1,378 @@ +import json +from pathlib import Path +from copy import deepcopy +from importlib.util import find_spec +from typing import Any, Union, Literal, Callable, ClassVar, Optional + +from nonebot.compat import PYDANTIC_V2 +import nonebot_plugin_localstore as store +from nonebot import require, get_plugin_config +from pydantic import Field, BaseModel, ConfigDict +from nonebot.matcher import current_bot, current_event + +from .log import ds_logger, tts_logger +from .exception import RequestException +from ._types import NOT_GIVEN, NotGivenOr +from .placehold_prompt import compile_template +from .compat import model_dump, model_validator + +if find_spec("nonebot_plugin_uninfo"): + require("nonebot_plugin_uninfo") + from nonebot_plugin_uninfo import get_session + + uninfo_enable = True +else: + uninfo_enable = False + +if find_spec("pydantic_yaml"): + from pydantic_yaml import parse_yaml_file_as + + yaml_enable = True +else: + yaml_enable = False + + +class ModelConfig: + ctx: ClassVar[dict[str, Any]] = {} + + def __init__(self) -> None: + self.file: Path = store.get_plugin_config_dir() / "config.json" + self.default_model: str = ds_config.get_enable_models()[0] + self.enable_md_to_pic: bool = ds_config.md_to_pic + self.available_tts_models: dict[str, dict[str, list[str]]] = {} + self.default_tts_model: Optional[str] = None + + self.prompt_func: Optional[Callable[[dict[str, Any]], str]] = None + self.load() + + def load(self): + if not self.file.exists(): + self.file.parent.mkdir(parents=True, exist_ok=True) + self.save() + return + + with open(self.file, encoding="utf-8") as f: + data = json.load(f) + self.default_model = data.get("default_model", self.default_model) + self.enable_md_to_pic = data.get("enable_md_to_pic", self.enable_md_to_pic) + self.default_tts_model = data.get("default_tts_model") + if isinstance(data.get("available_tts_models"), dict): + self.available_tts_models = data.get("available_tts_models") + + enable_models = ds_config.get_enable_models() + if self.default_model not in enable_models: + self.default_model = enable_models[0] + self.save() + if self.enable_md_to_pic != ds_config.md_to_pic: + self.enable_md_to_pic = ds_config.md_to_pic + self.save() + if self.available_tts_models and self.default_tts_model not in ( + list(self.available_tts_models.keys()) + tts_config.get_enable_tts() + ): + self.default_tts_model = list(self.available_tts_models.keys())[0] + self.save() + if not self.available_tts_models and self.default_tts_model: + self.save() + + def save(self): + config_data = { + "default_model": self.default_model, + "enable_md_to_pic": self.enable_md_to_pic, + } + if self.default_tts_model in (list(self.available_tts_models.keys()) + tts_config.get_enable_tts()): + config_data["default_tts_model"] = self.default_tts_model + if self.available_tts_models: + config_data["available_tts_models"] = self.available_tts_models + with open(self.file, "w", encoding="utf-8") as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + self.prompt_func = None + self.load() + + def set_prompt_func(self, prompt: str): + self.prompt_func = compile_template(prompt) + + async def update_prompt(self): + if uninfo_enable is True: + self.ctx.update(session=await get_session(current_bot.get(), current_event.get())) # type: ignore + if self.prompt_func is not None: + return self.prompt_func(deepcopy(self.ctx)) + raise ValueError("Prompt function not set") + + async def get_prompt(self) -> str: + return await self.update_prompt() + + +class CustomModel(BaseModel): + name: str + """Model Name""" + base_url: str = "https://api.deepseek.com" + """Custom base URL for this model (optional)""" + alias: Optional[str] = None + """Model alias name""" + api_key: Optional[str] = None + """Custom API Key for the model (optional)""" + prompt: Optional[str] = None + """Custom character preset for the model (optional)""" + proxy: Optional[str] = None + """A proxy URL where all the deepseek's traffic should be routed""" + stream: Optional[bool] = Field(default=None) + """Streaming""" + max_tokens: int = Field(default=4090, gt=1, lt=8192) + """ + 限制一次请求中模型生成 completion 的最大 token 数 + - `deepseek-chat`: Integer between 1 and 8192. Default is 4090. + - `deepseek-reasoner`: Default is 4K, maximum is 8K. + """ + frequency_penalty: Union[int, float] = Field(default=0, ge=-2, le=2) + """ + Discourage the model from repeating the same words or phrases too frequently within the generated text + """ + presence_penalty: Union[int, float] = Field(default=0, ge=-2, le=2) + """Encourage the model to include a diverse range of tokens in the generated text""" + stop: Optional[Union[str, list[str]]] = Field(default=None) + """ + Stop generating tokens when encounter these words. + Note that the list contains a maximum of 16 string. + """ + temperature: Union[int, float] = Field(default=1, ge=0, le=2) + """Sampling temperature. It is not recommended to used it with top_p""" + top_p: Union[int, float] = Field(default=1, ge=0, le=1) + """Alternatives to sampling temperature. It is not recommended to used it with temperature""" + logprobs: NotGivenOr[Union[bool, None]] = Field(default=NOT_GIVEN) + """Whether to return the log probability of the output token.""" + top_logprobs: NotGivenOr[int] = Field(default=NOT_GIVEN, le=20) + """Specifies that the most likely token be returned at each token position.""" + + if PYDANTIC_V2: + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + else: + + class Config: + extra = "allow" + arbitrary_types_allowed = True + + @model_validator(mode="before") + @classmethod + def check_max_token(cls, data: Any) -> Any: + if isinstance(data, dict): + name = data.get("name") + + if "max_tokens" not in data: + if name == "deepseek-reasoner": + data["max_tokens"] = 4000 + else: + data["max_tokens"] = 4090 + + stop = data.get("stop") + if isinstance(stop, list) and len(stop) >= 16: + raise ValueError("字段 `stop` 最多允许设置 16 个字符") + + if name == "deepseek-chat": + temperature = data.get("temperature") + top_p = data.get("top_p") + if temperature and top_p: + ds_logger("WARNING", "不建议同时修改 `temperature` 和 `top_p` 字段") + + top_logprobs = data.get("top_logprobs") + logprobs = data.get("logprobs") + if top_logprobs and logprobs is False: + raise ValueError("指定 `top_logprobs` 参数时,`logprobs` 必须为 True") + + elif name == "deepseek-reasoner": + max_tokens = data.get("max_tokens") + if max_tokens and max_tokens > 8000: + ds_logger("WARNING", f"模型 {name} `max_tokens` 字段最大为 8000") + + unsupported_params = ["temperature", "top_p", "presence_penalty", "frequency_penalty"] + params_present = [param for param in unsupported_params if param in data] + if params_present: + ds_logger("WARNING", f"模型 {name} 不支持设置 {', '.join(params_present)}") + + logprobs = data.get("logprobs") + top_logprobs = data.get("top_logprobs") + if logprobs or top_logprobs: + raise ValueError(f"模型 {name} 不支持设置 logprobs、top_logprobs") + + return data + + def to_dict(self): + return model_dump( + self, exclude_unset=True, exclude_none=True, exclude={"name", "base_url", "alias", "api_key", "prompt"} + ) + + +class CustomTTS(BaseModel): + name: str + """TTS Preset Parameters Name""" + version: str = "v4" + """GPT-Sovits API Version""" + model_name: str + """TTS Model Name""" + prompt_text_lang: str = "中文" + """language of the prompt text for the reference audio""" + emotion: str = "默认" + """Emotion""" + text_lang: str = "多语种混合" + """language of the text to be synthesized""" + top_k: int = Field(default=10, ge=1, le=100) + """top k sampling""" + top_p: Union[int, float] = Field(default=1, ge=0.01, le=1) + """top p sampling""" + temperature: Union[int, float] = Field(default=1, ge=0.01, le=1) + """temperature for sampling""" + text_split_method: str = "按标点符号切" + """Text Split Method""" + batch_size: int = Field(default=10, gt=1, lt=200) + """ batch size for inference""" + batch_threshold: Union[int, float] = Field(default=0.75, ge=0, le=1) + """threshold for batch splitting.""" + split_bucket: bool = True + """whether to split the batch into multiple buckets.""" + speed_facter: Union[int, float] = Field(default=1, ge=0.01, le=2) + """control the speed of the synthesized audio.""" + fragment_interval: Union[int, float] = Field(default=0.3, ge=0.01, le=1) + """Fragment Interval""" + media_type: Literal["wav", "ogg", "acc"] = "wav" + """Media Output Type""" + parallel_infer: bool = True + """Parallel Infer""" + repetition_penalty: Union[int, float] = Field(default=1.35, ge=0, le=2) + """repetition penalty for T2S model.""" + seed: int = -1 + """random seed for reproducibility.""" + sample_steps: int = 16 + """Number of steps sampled.""" + if_sr: bool = False + """whether to use super-resolution model.""" + + if PYDANTIC_V2: + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + else: + + class Config: + extra = "allow" + arbitrary_types_allowed = True + + def to_dict(self): + return model_dump(self, exclude_none=True, exclude={"name", "model_name", "speaker_name"}) + + +class TimeoutConfig(BaseModel): + api_request: int = Field(default=100) + """API request timeout (Not applicable for streaming)""" + user_input: int = Field(default=60) + """User input timeout""" + + +class ScopedConfig(BaseModel): + api_key: str = "" + """Your API Key from deepseek""" + enable_models: list[CustomModel] = [ + CustomModel(name="deepseek-chat"), + CustomModel(name="deepseek-reasoner"), + ] + """List of models configurations""" + prompt: str = "" + """Character Preset""" + md_to_pic: bool = False + """Text to Image""" + enable_send_thinking: bool = False + """Whether to send model thinking chain""" + timeout: Union[int, TimeoutConfig] = Field(default_factory=TimeoutConfig) + """Timeout""" + stream: bool = False + """Stream""" + enable_group_context: bool = False + """Whether to persist and share context in group chats across commands""" + max_group_history: int = Field(default=20, ge=1, le=100) + """Maximum number of messages to keep in group context""" + group_context_prefix: bool = True + """Whether to prefix user identity in group context messages""" + + def get_enable_models(self) -> list[str]: + return [model.alias if model.alias else model.name for model in self.enable_models] + + def get_model_url(self, model_name: str) -> str: + """Get the base_url corresponding to the model""" + for model in self.enable_models: + if model.name == model_name: + return model.base_url + raise ValueError(f"Model {model_name} not enabled") + + def get_model_config(self, model_name: str) -> CustomModel: + """Get model config""" + for model in self.enable_models: + if model.name == model_name or model.alias == model_name: + return model + raise ValueError(f"Model {model_name} not enabled") + + +class ScopedTTSConfig(BaseModel): + enable_models: Union[list[CustomTTS], bool] = False + """List of TTS models configurations""" + base_url: str = "" + """Your GPT-Sovits API Url """ + access_token: str = "" + """Your GPT-Sovits API Access Token""" + tts_version: str = "v4" + """Your GPT-Sovits API Version""" + dl_url: str = "" + """audio download url""" + timeout: int = Field(default=60) + + @model_validator(mode="before") + @classmethod + def check_audio_dl_url(cls, data: dict) -> dict: + if not data.get("audio_dl_url") and data.get("base_url"): + data["audio_dl_url"] = data["base_url"] + return data + + def get_enable_tts(self) -> list[str]: + if isinstance(self.enable_models, bool): + return [] + return [model.name for model in self.enable_models] + + async def get_available_tts(self) -> dict[str, dict[str, list[str]]]: + from .apis import API + + try: + tts_models = await API.get_tts_models() + preset_dict = {model.model_name: model.language_emotions for model in tts_models} + except RequestException as e: + preset_dict = {} + tts_logger("WARNING", f"获取 TTS 模型列表失败: {e}") + return preset_dict + + def get_tts_model(self, preset_name: str) -> CustomTTS: + """Get TTS model config""" + if not isinstance(self.enable_models, bool): + for model in self.enable_models: + if model.name == preset_name and f"{model.model_name}" in json_config.available_tts_models: + return model + if preset_name in json_config.available_tts_models: + prompt_text_lang = list(json_config.available_tts_models[preset_name].keys())[0] + return CustomTTS(name=preset_name, model_name=preset_name, prompt_text_lang=prompt_text_lang) + raise ValueError(f"TTS Model {preset_name} not valid") + + +class Config(BaseModel): + deepseek: ScopedConfig = Field(default_factory=ScopedConfig) + """DeepSeek Plugin Config""" + deepseek_tts: ScopedTTSConfig = Field(default_factory=ScopedTTSConfig) + """DeepSeek TTS Plugin Config""" + deepseek_external_config: Optional[str] = None + """External YAML configuration file path""" + + @model_validator(mode="after") + def load_external_config(self) -> "Config": + if self.deepseek_external_config and yaml_enable: + config_path = Path(self.deepseek_external_config) + + self = parse_yaml_file_as(Config, config_path) # type: ignore + return self + + +ds_config = (get_plugin_config(Config)).deepseek +tts_config = (get_plugin_config(Config)).deepseek_tts +json_config = ModelConfig() +ds_logger("DEBUG", f"load deepseek model: {ds_config.get_enable_models()}") diff --git a/src/plugins/nonebot_plugin_deepseek/exception.py b/src/plugins/nonebot_plugin_deepseek/exception.py new file mode 100644 index 0000000..ef2de21 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/exception.py @@ -0,0 +1,9 @@ +from nonebot.exception import NoneBotException + + +class Exception(NoneBotException): + """异常基类""" + + +class RequestException(Exception): + """请求错误""" diff --git a/src/plugins/nonebot_plugin_deepseek/extension.py b/src/plugins/nonebot_plugin_deepseek/extension.py new file mode 100644 index 0000000..2e2e61c --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/extension.py @@ -0,0 +1,44 @@ +import inspect +from typing import Union + +from nonebot.typing import T_State +from nonebot_plugin_alconna.uniseg import UniMessage +from nonebot_plugin_alconna.extension import Extension +from nonebot.internal.adapter import Bot, Event, Message +from nonebot_plugin_alconna import Text, Alconna, Arparma, OptionResult + + +class CleanDocExtension(Extension): + @property + def priority(self) -> int: + return 15 + + @property + def id(self) -> str: + return "CleanDoc" + + async def send_wrapper(self, bot: Bot, event: Event, send: Union[str, Message, UniMessage]): + plain_text = send if isinstance(send, (Message, UniMessage)) else inspect.cleandoc(send) + return plain_text + + +class ParseExtension(Extension): + @property + def priority(self) -> int: + return 20 + + @property + def id(self) -> str: + return "ParseExtension" + + async def parse_wrapper(self, bot: Bot, state: T_State, event: Event, res: Arparma) -> None: + if res.subcommands.get("model") and not res.subcommands["model"].options: + res.subcommands["model"].options.setdefault("list", OptionResult()) + elif res.subcommands.get("tts") and not res.subcommands["tts"].options: + res.subcommands["tts"].options.setdefault("list", OptionResult()) + + return None + + async def receive_wrapper(self, bot: Bot, event: Event, command: Alconna, receive: UniMessage) -> UniMessage: + receive = receive.include(Text) + return receive diff --git a/src/plugins/nonebot_plugin_deepseek/function_call/__init__.py b/src/plugins/nonebot_plugin_deepseek/function_call/__init__.py new file mode 100644 index 0000000..28066f9 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/function_call/__init__.py @@ -0,0 +1,2 @@ +from . import builtins as builtins +from .registry import registry as registry diff --git a/src/plugins/nonebot_plugin_deepseek/function_call/builtins/__init__.py b/src/plugins/nonebot_plugin_deepseek/function_call/builtins/__init__.py new file mode 100644 index 0000000..72b2e36 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/function_call/builtins/__init__.py @@ -0,0 +1 @@ +from . import website_summary as website_summary diff --git a/src/plugins/nonebot_plugin_deepseek/function_call/builtins/website_summary.py b/src/plugins/nonebot_plugin_deepseek/function_call/builtins/website_summary.py new file mode 100644 index 0000000..6e90c5d --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/function_call/builtins/website_summary.py @@ -0,0 +1,26 @@ +from bs4 import BeautifulSoup +from httpx import AsyncClient + +from ..registry import registry + +headers = { + "User-Agent": "Firefox/90.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0" # noqa: E501 +} + + +@registry.register() +async def get_web_content(url: str): + """通过链接获取网页内容 + + 参数: + url: 网页链接 + """ + + async with AsyncClient() as client: + response = await client.get(url, headers=headers) + if response.status_code != 200: + return "获取网页内容失败:" + str(response.status_code) + + soup = BeautifulSoup(response.text, "html.parser") + + return soup.get_text() diff --git a/src/plugins/nonebot_plugin_deepseek/function_call/registry.py b/src/plugins/nonebot_plugin_deepseek/function_call/registry.py new file mode 100644 index 0000000..36963e6 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/function_call/registry.py @@ -0,0 +1,214 @@ +import re +import sys +import json +import inspect +import functools +import importlib.util +from pathlib import Path +from collections.abc import Callable +from typing import Any, Union, Optional + +from ..log import ds_logger +from ..schemas import ToolCalls + + +class FunctionRegistry: + def __init__(self): + self._registry = {} + self._type_mapping = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + list: "array", + dict: "object", + Any: "any", + } + + def load(self, *directories: str, base_dir: Optional[Union[Path, str]] = None) -> None: + base_path = Path(base_dir).resolve() if base_dir else Path.cwd() + + if str(base_path) not in sys.path: + sys.path.insert(0, str(base_path)) + + for rel_dir in directories: + dir_path = (base_path / rel_dir).resolve() + + if not (dir_path / "__init__.py").exists(): + ds_logger("WARNING", f"Skipping non-package directory: {dir_path}") + continue + + for py_file in dir_path.glob("*.py"): + if py_file.name == "__init__.py": + continue + + package_path = dir_path.relative_to(base_path) + module_name = ".".join(package_path.parts + (py_file.stem,)) + + if module_name in sys.modules: + continue + + spec = importlib.util.spec_from_file_location(module_name, py_file) + if not spec or not spec.loader: + continue + + try: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + except Exception as e: + ds_logger("ERROR", f"Failed to loaded {module_name}: {str(e)}") + + def register(self, name: Optional[str] = None, description: Optional[str] = None): + def decorator(func: Callable): + nonlocal name, description + func_name = name or func.__name__ + + sig = inspect.signature(func) + parameters = self._parse_parameters(func, sig) + + func_description = description or self._parse_description(func.__doc__) # type: ignore + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + self._registry[func_name] = { + "name": func_name, + "description": func_description, + "raw_parameters": parameters, + "func": wrapper, + } + ds_logger("DEBUG", f'Succeeded to load function "{func_name}"') + return wrapper + + return decorator + + def _parse_description(self, docstring: str) -> str: + return docstring.split("\n\n")[0].strip() if docstring else "" + + def _parse_parameters(self, func: Callable, sig: inspect.Signature) -> dict: + param_docs = self._parse_param_docs(func.__doc__ or "") + parameters = {} + + for name, param in sig.parameters.items(): + param_type = param.annotation if param.annotation != inspect.Parameter.empty else Any + parameters[name] = { + "type": param_type, + "description": param_docs.get(name, ""), + "required": param.default == inspect.Parameter.empty, + } + return parameters + + def _parse_param_docs(self, docstring: str) -> dict[str, str]: + param_docs = {} + if not docstring: + return param_docs + + args_section = False + for line in docstring.split("\n"): + line = line.strip() + if line.lower() in ("args:", "参数:"): + args_section = True + continue + if args_section: + if not line: + continue + if match := re.match(r"^(\w+)(\s*\(.*\))?:\s*(.*)", line): + param_docs[match.group(1)] = match.group(3).strip() + else: + args_section = False + return param_docs + + def _convert_type(self, py_type: type) -> str: + return self._type_mapping.get(py_type, "any") + + def to_json(self) -> list: + result = [] + for func_name, info in self._registry.items(): + params = info["raw_parameters"] + properties = {} + required = [] + + for name, param in params.items(): + properties[name] = { + "type": self._convert_type(param["type"]), + "description": param["description"], + } + if param["required"]: + required.append(name) + + func_schema = { + "type": "function", + "function": { + "name": func_name, + "description": info["description"], + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }, + } + result.append(func_schema) + return result + + async def execute_tool_call(self, tool_call: ToolCalls) -> Any: + func_name = tool_call.function.name + func_info = self._registry.get(func_name) + if not func_info: + raise ValueError(f"Function '{func_name}' is not registered.") + + args: dict[str, Any] = json.loads(tool_call.function.arguments) + converted_args = {} + + for param_name, param_spec in func_info["raw_parameters"].items(): + if param_name not in args: + if param_spec["required"]: + raise ValueError(f"Missing required parameter: {param_name}") + continue + value = args[param_name] + param_type = param_spec["type"] + try: + converted_value = self._convert_value(value, param_type) + except Exception as e: + raise ValueError( + f"Parameter '{param_name}' value {value} cannot be converted to {param_type}: {e}" # noqa: E501 + ) from e + converted_args[param_name] = converted_value + + func = func_info["func"] + + result = func(**converted_args) + if inspect.isawaitable(result): + result = await result + + ds_logger("DEBUG", f"Calling {func_name} function") + return result + + def _convert_value(self, value: Any, param_type: type) -> Any: + if param_type is Any: + return value + + if param_type is bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lower_val = value.lower() + if lower_val == "true": + return True + elif lower_val == "false": + return False + else: + raise ValueError(f"Invalid boolean string: {value}") + return bool(value) + + try: + return param_type(value) + except (TypeError, ValueError): + if param_type in (int, float): + return param_type(str(value)) + raise + + +registry = FunctionRegistry() diff --git a/src/plugins/nonebot_plugin_deepseek/group_context.py b/src/plugins/nonebot_plugin_deepseek/group_context.py new file mode 100644 index 0000000..7f730f8 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/group_context.py @@ -0,0 +1,85 @@ +from importlib.util import find_spec +from typing import Optional + +from nonebot import require +from nonebot.adapters import Event +from nonebot.matcher import current_bot + +from .config import ds_config + +if find_spec("nonebot_plugin_uninfo"): + require("nonebot_plugin_uninfo") + uninfo_enable = True +else: + uninfo_enable = False + + +# {session_id: [message, ...]} +_group_contexts: dict[str, list[dict[str, str]]] = {} + + +async def get_group_session_id(event: Event) -> Optional[str]: + """Return a session id for group/guild chats, or None for private chats.""" + if uninfo_enable: + try: + from nonebot_plugin_uninfo import get_session, SceneType + + session = await get_session(current_bot.get(), event) + if session is None: + return None + scene = session.scene + if scene.type == SceneType.GROUP: + return f"{session.scope}:group:{scene.id}" + if scene.type == SceneType.GUILD: + channel = scene.parent.id if scene.parent else scene.id + return f"{session.scope}:guild:{channel}" + except Exception: + pass + + # Fallback for common adapters + message_type = getattr(event, "message_type", None) + if message_type == "group": + group_id = getattr(event, "group_id", None) + if group_id is not None: + return f"fallback:group:{group_id}" + return None + + +async def get_user_name(event: Event) -> str: + user_id = event.get_user_id() + if uninfo_enable: + try: + from nonebot_plugin_uninfo import get_session + + session = await get_session(current_bot.get(), event) + if session is None: + return user_id + if session.user.name: + return session.user.name + return session.user.id + except Exception: + pass + try: + sender = getattr(event, "sender", None) + if sender: + nickname = getattr(sender, "nickname", None) + if nickname: + return str(nickname) + except Exception: + pass + return user_id + + +def get_context(session_id: str) -> list[dict[str, str]]: + return _group_contexts.get(session_id, []) + + +def set_context(session_id: str, context: list[dict[str, str]]) -> None: + limit = ds_config.max_group_history + if len(context) > limit: + context = context[-limit:] + _group_contexts[session_id] = context + + +def clear_context(session_id: str) -> None: + _group_contexts.pop(session_id, None) diff --git a/src/plugins/nonebot_plugin_deepseek/hook.py b/src/plugins/nonebot_plugin_deepseek/hook.py new file mode 100644 index 0000000..8a099de --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/hook.py @@ -0,0 +1,29 @@ +from nonebot import get_driver +from nonebot_plugin_alconna import command_manager +from nonebot_plugin_localstore import get_plugin_cache_dir + +from .log import ds_logger, tts_logger +from .config import tts_config, json_config + +driver = get_driver() +cach_dir = get_plugin_cache_dir() / "shortcut.db" + + +@driver.on_startup +async def _() -> None: + if tts_config.enable_models: + if not json_config.available_tts_models: + available_models = await tts_config.get_available_tts() + json_config.available_tts_models = available_models + json_config.save() + tts_logger("DEBUG", f"Loaded available TTS models: {len(available_models)}") + else: + tts_logger("DEBUG", f"Loaded available TTS models: {len(json_config.available_tts_models)}") + command_manager.load_cache(cach_dir) + ds_logger("DEBUG", "DeepSeek shortcuts cache loaded") + + +@driver.on_shutdown +async def _() -> None: + command_manager.dump_cache(cach_dir) + ds_logger("DEBUG", "DeepSeek shortcuts cache dumped") diff --git a/src/plugins/nonebot_plugin_deepseek/log.py b/src/plugins/nonebot_plugin_deepseek/log.py new file mode 100644 index 0000000..afa86b2 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/log.py @@ -0,0 +1,4 @@ +from nonebot.utils import logger_wrapper + +ds_logger = logger_wrapper(logger_name="Plugin-DeepSeek") +tts_logger = logger_wrapper(logger_name="Plugin-DeepSeek-TTS") diff --git a/src/plugins/nonebot_plugin_deepseek/placehold_prompt.py b/src/plugins/nonebot_plugin_deepseek/placehold_prompt.py new file mode 100644 index 0000000..8df2861 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/placehold_prompt.py @@ -0,0 +1,173 @@ +import re +import ast +from enum import Enum +from typing import Callable + + +class parse_flag(Enum): + text = 0 + var = 1 + block = 2 + endblock = 3 + + +def parse_template(template: str): + pattern = re.compile(r"({%.*?%}|{{.*?}})", re.DOTALL) + parts = pattern.split(template) + tokens: list[tuple[parse_flag, str]] = [] + # print(parts) + for part in parts: + if not parts: + continue + elif part.startswith("{{"): + content = part[2:-2].strip() + tokens.append((parse_flag.var, content)) + + elif part.startswith("{%"): + content = part[2:-2].strip() + if content.startswith("end"): + tokens.append((parse_flag.endblock, content[3:])) + else: + tokens.append((parse_flag.block, content)) + else: + if part: + tokens.append((parse_flag.text, part)) + + return tokens + + +def parse_attr_chain(expr: str): + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError: + return None + + parts = [] + node = tree.body + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + parts.append(node.id) + parts.reverse() + return parts + return None + + +def extract_variables(expr): + """改进版变量提取函数,支持所有语法场景""" + variables = set() + + # 尝试解析为属性链(如 session.user.name) + parts = parse_attr_chain(expr) + if parts: + variables.add(parts[0]) + return variables + + # 通用AST解析 + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError: + try: + tree = ast.parse(expr, mode="exec") + except SyntaxError: + return variables + + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + variables.add(node.id) + return variables + + +def compile_template(template: str) -> Callable[..., str]: + tokens = parse_template(template) + # print(tokens) + variables = set() + iterables = set() + + # 收集所有需要上下文访问的变量 + for token_type, content in tokens: + if token_type == parse_flag.var: + variables.update(extract_variables(content)) + elif token_type == parse_flag.block: + parts = content.split() + if parts[0] == "for" and len(parts) >= 4 and parts[2] == "in": + iter_expr = " ".join(parts[3:]) + iter_vars = extract_variables(iter_expr) + variables.update(iter_vars) + iterables.add(parts[3]) if len(parts) >= 4 else None + elif parts[0] in ("if", "elif"): + cond_vars = extract_variables(" ".join(parts[1:])) + variables.update(cond_vars) + # 生成代码 + code = [ + "def render(context):", + ' def safe_get(obj, attr, default=""):', + " if isinstance(obj, dict):", + " return obj.get(attr, default)", + " else:", + " return getattr(obj, attr, default)", + " result = []", + " _ctx = context", + ] + + indent_level = 1 + stack = [] + # print(tokens) + # 变量初始化(全部通过safe_get访问) + for var in variables: + code.append(f' _ctx["{var}"] = safe_get(context, "{var}")') + + indent_level = 1 + stack = [] + last_type: parse_flag = parse_flag.text + for token_type, content in tokens: + if token_type == parse_flag.text: + # 转义特殊字符并保留换行控制 + if last_type in (parse_flag.block, parse_flag.endblock): + content = content.replace("\n", "", 1) + escaped = content.replace("\n", "\\n").replace('"', '\\"') + line = " " * indent_level + f'result.append("{escaped.rstrip()}")' + code.append(line) + elif token_type == parse_flag.var: + parts = parse_attr_chain(content) + if parts: + # 生成链式访问代码 + access_chain = f'safe_get(_ctx, "{parts[0]}")' + for part in parts[1:]: + access_chain = f'safe_get({access_chain}, "{part}")' + code.append(" " * indent_level + f"result.append({access_chain})") + else: + code.append(" " * indent_level + f'result.append(safe_get(_ctx, "{content}"))') + elif token_type == parse_flag.block: + parts = content.split() + if parts[0] == "for": + # 生成迭代器 + loop_var = parts[1] + iter_expr = " ".join(parts[3:]) + code.append(" " * indent_level + f'for {loop_var} in safe_get(_ctx, "{parts[3]}", []):') + code.append(" " * indent_level + f' _ctx["{loop_var}"] = {loop_var}') # 注入循环变量到上下文 + stack.append("for") + indent_level += 1 + elif parts[0] == "if": + # 生成条件表达式 + cond = " ".join([f'safe_get(_ctx, "{word}")' if word.isidentifier() else word for word in parts[1:]]) + code.append(" " * indent_level + f"if {cond}:") + stack.append("if") + indent_level += 1 + elif parts[0] == "elif": + cond = " ".join([f'safe_get(_ctx, "{word}")' if word.isidentifier() else word for word in parts[1:]]) + code.append(" " * (indent_level - 1) + f"elif {cond}:") + elif parts[0] == "else": + code.append(" " * (indent_level - 1) + "else:") + elif token_type == parse_flag.endblock: + if stack: + stack.pop() + indent_level = max(1, indent_level - 1) + last_type = token_type + + code.append(' return "".join(result)') + code_str = "\n".join(code) # Python Code + locals_dict = {} + exec(code_str, globals(), locals_dict) + return locals_dict["render"] diff --git a/src/plugins/nonebot_plugin_deepseek/resources/tts_models.css b/src/plugins/nonebot_plugin_deepseek/resources/tts_models.css new file mode 100644 index 0000000..75c007c --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/resources/tts_models.css @@ -0,0 +1,45 @@ +body { + padding: 2rem; + background-color: #f4f4f4; +} + +.main-box { + width: 100%; +} + +.text { + width: 100%; +} + +.custom-header { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #ccc; + margin-bottom: 1rem; +} + +.models-container { + column-width: 320px; + column-gap: 1.5rem; + font-size: 16px; +} + +.models-container > div { + padding: 0.1em 0.5em; + overflow-wrap: break-word; + line-height: 1.6; + break-inside: avoid; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; +} + + +.models-container > div:nth-child(odd) { + background-color: #ffffff; +} + +.models-container > div:nth-child(even) { + background-color: #ffebcd; +} diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/__init__.py b/src/plugins/nonebot_plugin_deepseek/schemas/__init__.py new file mode 100644 index 0000000..b42e879 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/__init__.py @@ -0,0 +1,8 @@ +from .balance import Balance as Balance +from .message import Message as Message +from .message import ToolCalls as ToolCalls +from .tts import TTSModelInfo as TTSModelInfo +from .chat import StreamChoice as StreamChoice +from .balance import BalanceInfo as BalanceInfo +from .chat import ChatCompletions as ChatCompletions +from .chat import StreamChoiceList as StreamChoiceList diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/balance.py b/src/plugins/nonebot_plugin_deepseek/schemas/balance.py new file mode 100644 index 0000000..9ed0b5e --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/balance.py @@ -0,0 +1,26 @@ +from typing import Literal +from dataclasses import dataclass + + +@dataclass +class BalanceInfo: + currency: Literal["CNY", "USD"] + """货币,人民币或美元""" + total_balance: str + """总的可用余额,包括赠金和充值余额""" + granted_balance: str + """未过期的赠金余额""" + topped_up_balance: str + """充值余额""" + + +@dataclass +class Balance: + """用户余额详情""" + + is_available: bool + """当前账户是否有余额可供 API 调用""" + balance_infos: list[BalanceInfo] + + def __post_init__(self) -> None: + self.balance_infos = [BalanceInfo(**info) if isinstance(info, dict) else info for info in self.balance_infos] diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/chat.py b/src/plugins/nonebot_plugin_deepseek/schemas/chat.py new file mode 100644 index 0000000..12232d7 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/chat.py @@ -0,0 +1,212 @@ +from dataclasses import dataclass +from typing_extensions import TypeAlias +from typing import Literal, Optional, cast + +from .usage import Usage +from .message import Message +from .logprobs import Logprobs + +FinishReasonType: TypeAlias = Literal["stop", "length", "content_filter", "tool_calls", "insufficient_system_resource"] + + +class Delta: + content: Optional[str] = None + "实际输出的内容" + reasoning_content: Optional[str] = None + "推理输出的内容" + role: Literal["assistant"] = "assistant" + "角色" + + def __init__( + self, + content: Optional[str] = None, + reasoning_content: Optional[str] = None, + role: Literal["assistant"] = "assistant", + **kwargs, + ) -> None: + self.content = content + self.reasoning_content = reasoning_content + self.role = role + + def update( + self, + content: Optional[str] = None, + reasoning_content: Optional[str] = None, + role: Optional[Literal["assistant"]] = None, + **kwargs, + ): + if content: + if self.content is None: + self.content = content + else: + self.content += content + if reasoning_content: + if self.reasoning_content is None: + self.reasoning_content = reasoning_content + else: + self.reasoning_content += reasoning_content + if role: + self.role = role + + +class StreamChoice: + finish_reason: Optional[FinishReasonType] = None + """模型停止生成 token 的原因""" + index: int + """该 completion 在模型生成的 completion 的选择列表中的索引""" + delta: Delta + """流式返回的一个 completion 增量。""" + logprobs: Optional[Logprobs] = None + """该 choice 的对数概率信息""" + + def __init__( + self, + index: int, + delta: dict, + *, + finish_reason: Optional[FinishReasonType] = None, + logprobs: Optional[dict] = None, + **kwargs, + ) -> None: + self.index = index + self.delta = Delta(**delta) + self.finish_reason = finish_reason + if isinstance(logprobs, dict): + self.logprobs = Logprobs(**logprobs) + + def update(self, other: "StreamChoice"): + self.delta.update( + content=other.delta.content, + reasoning_content=other.delta.reasoning_content, + role=other.delta.role, + ) + if other.finish_reason: + self.finish_reason = other.finish_reason + if other.logprobs: + self.logprobs = other.logprobs + return self + + +class StreamChoiceList: + id: str + """该对话的唯一标识符。""" + created: int + """创建聊天完成时的 Unix 时间戳(以秒为单位)""" + model: str + """生成该 completion 的模型名""" + object: Literal["chat.completion", "chat.completion.chunk"] + """对象的类型, 其值为 `chat.completion`。流式传输时, 其值为 `chat.completion.chunk`""" + system_fingerprint: Optional[str] = None + """该指纹代表模型运行的后端配置""" + usage: Optional[Usage] = None + """该对话补全请求的用量信息""" + + def __init__( + self, + id: str, + created: int, + model: str, + object: Literal["chat.completion", "chat.completion.chunk"], + choices: list[dict], + system_fingerprint: Optional[str] = None, + usage=None, + **kwargs, + ) -> None: + self.choices = [StreamChoice(**i) for i in choices] + self.choices_index = [i.index for i in self.choices] + self.id = id + self.created = created + self.model = model + self.object = object + self.system_fingerprint = system_fingerprint + if isinstance(usage, dict): + self.usage = Usage(**usage) + + def __add__(self, other: "StreamChoiceList"): + if self.system_fingerprint != other.system_fingerprint: + self.system_fingerprint = other.system_fingerprint + if self.usage != other.usage: + self.usage = other.usage + + for other_choice in other.choices: + if other_choice.index not in self.choices_index: + self.choices.append(other_choice) + self.choices_index.append(other_choice.index) + continue + for self_choice in self.choices: + if self_choice.index == other_choice.index: + self_choice.update(other_choice) + + return self + + def transform(self): + return ChatCompletions( + self.id, + [ + Choice( + finish_reason=cast(FinishReasonType, choice.finish_reason), + index=choice.index, + message=Message( + choice.delta.role, + content=choice.delta.content, + reasoning_content=choice.delta.reasoning_content, + ), + logprobs=choice.logprobs, + ) + for choice in self.choices + ], + self.created, + self.model, + self.object, + self.usage + if self.usage + else Usage( + completion_tokens=0, + prompt_tokens=0, + total_tokens=0, + ), + self.system_fingerprint, + ) + + +@dataclass +class Choice: + """模型生成的 completion 的选择列表""" + + finish_reason: FinishReasonType + """模型停止生成 token 的原因""" + index: int + """该 completion 在模型生成的 completion 的选择列表中的索引""" + message: Message + """模型生成的 completion 消息""" + logprobs: Optional[Logprobs] = None + """该 choice 的对数概率信息""" + + def __post_init__(self) -> None: + if isinstance(self.message, dict): + self.message = Message(**self.message) + if isinstance(self.logprobs, dict): + self.logprobs = Logprobs(**self.logprobs) + + +@dataclass +class ChatCompletions: + id: str + """该对话的唯一标识符。""" + choices: list[Choice] + """模型生成的 completion 的选择列表""" + created: int + """创建聊天完成时的 Unix 时间戳(以秒为单位)""" + model: str + """生成该 completion 的模型名""" + object: Literal["chat.completion", "chat.completion.chunk"] + """对象的类型, 其值为 `chat.completion`。流式传输时, 其值为 `chat.completion.chunk`""" + usage: Usage + """该对话补全请求的用量信息""" + system_fingerprint: Optional[str] = None + """该指纹代表模型运行的后端配置""" + + def __post_init__(self) -> None: + self.choices = [Choice(**choice) if isinstance(choice, dict) else choice for choice in self.choices] + if isinstance(self.usage, dict): + self.usage = Usage(**self.usage) diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/logprobs.py b/src/plugins/nonebot_plugin_deepseek/schemas/logprobs.py new file mode 100644 index 0000000..6f8eedf --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/logprobs.py @@ -0,0 +1,64 @@ +from typing import Optional +from dataclasses import dataclass + + +@dataclass +class TopLogprobs: + """ + 一个包含在该输出位置上,输出概率 top N 的 token 的列表,以及它们的对数概率 + 在罕见情况下,返回的 token 数量可能少于请求参数中指定的 `top_logprobs` 值 + """ + + token: str + """输出的 token""" + logprob: int + """ + 该 token 的对数概率 + `-9999.0` 代表该 token 的输出概率极小,不在 top 20 最可能输出的 token 中""" + bytes: Optional[list[int]] = None + """ + 一个包含该 token UTF-8 字节表示的整数列表 + 一般在一个 UTF-8 字符被拆分成多个 token 来表示时有用 + 如果 token 没有对应的字节表示,则该值为 `None` + """ + + +@dataclass +class Content: + """一个包含输出 token 对数概率信息的列表""" + + token: str + """输出的 token""" + logprob: int + """ + 该 token 的对数概率 + `-9999.0` 代表该 token 的输出概率极小,不在 top 20 最可能输出的 token 中""" + top_logprobs: list[TopLogprobs] + """ + 一个包含在该输出位置上,输出概率 top N 的 token 的列表,以及它们的对数概率 + 在罕见情况下,返回的 token 数量可能少于请求参数中指定的 `top_logprobs` 值 + """ + bytes: Optional[list[int]] = None + """ + 一个包含该 token UTF-8 字节表示的整数列表 + 一般在一个 UTF-8 字符被拆分成多个 token 来表示时有用 + 如果 token 没有对应的字节表示,则该值为 `None` + """ + + def __post_init__(self) -> None: + if self.top_logprobs: + self.top_logprobs = [ + TopLogprobs(**top_logprob) if isinstance(top_logprob, dict) else top_logprob + for top_logprob in self.top_logprobs + ] + + +@dataclass +class Logprobs: + """该 choice 的对数概率信息""" + + content: Optional[list[Content]] = None + + def __post_init__(self) -> None: + if self.content: + self.content = [Content(**content) if isinstance(content, dict) else content for content in self.content] diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/message.py b/src/plugins/nonebot_plugin_deepseek/schemas/message.py new file mode 100644 index 0000000..7000161 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/message.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from typing import Literal, Optional + + +@dataclass +class Function: + """模型调用的 function""" + + name: str + """模型调用的 function 名""" + arguments: str + """要调用的 function 的参数,由模型生成,格式为 JSON。""" + + +@dataclass +class ToolCalls: + """模型生成的 tool 调用,例如 function 调用。""" + + index: int + id: str + """tool 调用的 ID""" + type: Literal["function"] + """tool 的类型。目前仅支持 `function`""" + function: Function + """模型调用的 function""" + + def __post_init__(self) -> None: + if isinstance(self.function, dict): + self.function = Function(**self.function) + + +@dataclass +class Message: + """模型生成的 completion 消息""" + + role: Literal["assistant"] + """生成这条消息的角色""" + content: Optional[str] = None + """该 completion 的内容""" + reasoning_content: Optional[str] = None + """ + 仅适用于 deepseek-reasoner 模型。内容为 assistant 消息中在最终答案之前的推理内容 + """ + tool_calls: Optional[list[ToolCalls]] = None + """模型生成的 tool 调用""" + + def __post_init__(self) -> None: + if self.tool_calls: + self.tool_calls = [ + ToolCalls(**tool_call) if isinstance(tool_call, dict) else tool_call for tool_call in self.tool_calls + ] diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/tts.py b/src/plugins/nonebot_plugin_deepseek/schemas/tts.py new file mode 100644 index 0000000..541fa44 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/tts.py @@ -0,0 +1,7 @@ +from clilte.core import dataclass + + +@dataclass +class TTSModelInfo: + model_name: str + language_emotions: dict[str, list[str]] diff --git a/src/plugins/nonebot_plugin_deepseek/schemas/usage.py b/src/plugins/nonebot_plugin_deepseek/schemas/usage.py new file mode 100644 index 0000000..e9a1f34 --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/schemas/usage.py @@ -0,0 +1,43 @@ +from typing import Optional +from dataclasses import dataclass + + +@dataclass +class PromptTokensDetails: + cached_tokens: Optional[int] = None + + +@dataclass +class CompletionTokensDetails: + """completion tokens 的详细信息。""" + + reasoning_tokens: Optional[int] = None + """推理模型所产生的思维链 token 数量""" + + +@dataclass +class Usage: + """该对话补全请求的用量信息""" + + completion_tokens: int + """模型 completion 产生的 token 数""" + prompt_tokens: int + """ + 用户 prompt 所包含的 token 数 + 该值等于 `prompt_cache_hit_token`s + `prompt_cache_miss_tokens` + """ + total_tokens: int + """该请求中,所有 token 的数量(prompt + completion)""" + prompt_tokens_details: Optional[PromptTokensDetails] = None + """我也不知道这是个啥,文档没写""" + prompt_cache_hit_tokens: Optional[int] = None + """用户 prompt 中,命中上下文缓存的 token 数""" + prompt_cache_miss_tokens: Optional[int] = None + """用户 prompt 中,未命中上下文缓存的 token 数""" + completion_tokens_details: Optional[CompletionTokensDetails] = None + + def __post_init__(self) -> None: + if isinstance(self.prompt_tokens_details, dict): + self.prompt_tokens_details = PromptTokensDetails(**self.prompt_tokens_details) + if isinstance(self.completion_tokens_details, dict): + self.completion_tokens_details = CompletionTokensDetails(**self.completion_tokens_details) diff --git a/src/plugins/nonebot_plugin_deepseek/utils.py b/src/plugins/nonebot_plugin_deepseek/utils.py new file mode 100644 index 0000000..75e9b4b --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/utils.py @@ -0,0 +1,269 @@ +import re +import importlib +from dataclasses import asdict +from collections.abc import Callable, Awaitable +from typing import Any, Union, Literal, Optional + +import httpx +from nonebot.adapters import Event +from nonebot.permission import User, Permission +from nonebot_plugin_waiter import Waiter, prompt +from nonebot_plugin_alconna import SupportAdapter +from nonebot.matcher import Matcher, current_event, current_matcher +from nonebot_plugin_alconna.uniseg import UniMsg, UniMessage, get_target, get_message_id, message_reaction + +from .apis import API +from .log import tts_logger +from .schemas import Message +from .exception import RequestException +from .function_call.registry import registry +from .config import CustomTTS, CustomModel, ds_config +from .group_context import get_user_name, get_context, set_context + + +class DeepSeekHandler: + def __init__( + self, + model: CustomModel, + is_to_pic: bool, + is_contextual: bool, + tts_model: Optional[CustomTTS] = None, + is_group_context: bool = False, + group_session_id: Optional[str] = None, + ) -> None: + self.model: CustomModel = model + self.is_to_pic: bool = is_to_pic + self.is_contextual: bool = is_contextual + self.tts_model: Optional[CustomTTS] = tts_model + self.is_group_context: bool = is_group_context + self.group_session_id: Optional[str] = group_session_id + self.event: Event = current_event.get() + self.matcher: Matcher = current_matcher.get() + self.message_id: str = get_message_id(self.event) + self.waiter: Waiter[Union[str, Literal[False]]] = self._setup_waiter() + + self.context: list[dict[str, Any]] = [] + if self.is_group_context and self.group_session_id: + self.context = get_context(self.group_session_id) + + self.md_to_pic: Union[Callable[..., Awaitable[bytes]], None] = ( + importlib.import_module("nonebot_plugin_htmlrender").md_to_pic if self.is_to_pic else None + ) + + async def handle(self, content: Optional[str]) -> None: + if content: + if self.is_group_context and ds_config.group_context_prefix: + user_name = await get_user_name(self.event) + self.context.append({"role": "user", "content": f"[{user_name}] {content}"}) + else: + self.context.append({"role": "user", "content": content}) + + await self._message_reaction("thinking") + + if not self.is_contextual: + await self._handle_single_conversion() + else: + await self._handle_multi_round_conversion() + + def _save_group_context(self) -> None: + if self.is_group_context and self.group_session_id: + set_context(self.group_session_id, self.context) + + async def _handle_single_conversion(self) -> None: + if message := await self._get_response_message(): + await self._send_response(message) + if self.is_group_context and self.group_session_id: + self.context.append(asdict(message)) + self._save_group_context() + + async def _handle_multi_round_conversion(self) -> None: + timeout = ds_config.timeout if isinstance(ds_config.timeout, int) else ds_config.timeout.user_input + async for resp in self.waiter(default=False, timeout=timeout): + await self._process_waiter_response(resp) + + if resp == "rollback": + continue + + message = await self._get_response_message() + if not message: + continue + + await self._send_response(message) + self.context.append(asdict(message)) + self._save_group_context() + + if await self._handle_tool_calls(message): + self.waiter.future.set_result("") + continue + + def _setup_waiter(self) -> Waiter[Union[str, Literal[False]]]: + permission = Permission(User.from_event(self.event, perm=self.matcher.permission)) + waiter = Waiter( + waits=["message"], + handler=self._waiter_handler, + matcher=self.matcher, + permission=permission, + ) + waiter.future.set_result("") + return waiter + + def _waiter_handler(self, msg: UniMsg, skip: bool = False) -> Union[str, Literal[False]]: + text = msg.extract_plain_text() + if not skip: + self.message_id = get_message_id() + if text in ["结束", "取消", "done"]: + return False + if text in ["回滚", "rollback"]: + return "rollback" + return text + + def _prompt_handler(self, msg: UniMsg) -> UniMsg: + self.message_id = get_message_id() + return msg + + async def _message_reaction(self, status: Literal["fail", "thinking", "done"]) -> None: + emoji_map = { + "fail": ["10060", "❌"], + "thinking": ["424", "👀"], + "done": ["144", "🎉"], + } + target = get_target(self.event) + if is_qq := target.adapter in ( + SupportAdapter.onebot11, + SupportAdapter.qq, + ): + emoji = emoji_map[status][0] + else: + emoji = emoji_map[status][1] + + if is_qq and target.private: + return + + await message_reaction(emoji, message_id=self.message_id) + + async def _process_waiter_response(self, resp: Union[bool, str]) -> None: + timeout = ds_config.timeout if isinstance(ds_config.timeout, int) else ds_config.timeout.user_input + + if resp == "" and not self.context: + _resp = await prompt( + "你想对 DeepSeek 说什么呢?", + handler=self._prompt_handler, + timeout=timeout, + ) + if _resp is None: + await UniMessage.text("等待超时").finish(reply_to=self.message_id) + resp = self._waiter_handler(_resp, skip=True) + + await self._message_reaction("thinking") + + if resp is False: + await UniMessage.text("已结束对话").finish(reply_to=self.message_id) + elif resp == "rollback": + await self._handle_rollback() + elif resp and isinstance(resp, str): + self.context.append({"role": "user", "content": resp}) + + async def _handle_rollback(self, steps: int = 1, by_error: bool = False) -> None: + rollback_per_step = 1 if by_error else 2 + required_length = steps * rollback_per_step + rollback_position = -rollback_per_step * steps + + if len(self.context) >= required_length: + self.context = self.context[:rollback_position] + action_desc = f"回滚 {steps} 条输入" if by_error else f"回滚 {steps} 轮对话" + status_msg = f"Oops! 连接异常,已自动{action_desc}。" if by_error else f"已{action_desc}。" + + remaining_context = ( + "空" if not self.context else f"{self.context[-1]['role']}: {self.context[-1]['content']}" + ) + + await UniMessage.text(f"{status_msg}当前上下文为:\n{remaining_context}\nuser:(等待输入)").send( + reply_to=self.message_id + ) + elif by_error and len(self.context) > 0: + self.context.clear() + await self._message_reaction("fail") + await UniMessage.text("Oops! 连接异常,请重新输入").send(reply_to=self.message_id) + else: + await UniMessage.text("无法回滚,当前对话记录为空").send(reply_to=self.message_id) + + async def _handle_tool_calls(self, message: Message) -> bool: + if not message.tool_calls: + return False + + try: + result = await registry.execute_tool_call(message.tool_calls[0]) + except Exception: + self.context.pop() + return False + + self.context.append( + { + "role": "tool", + "tool_call_id": message.tool_calls[0].id, + "content": result, + } + ) + return True + + async def _get_response_message(self) -> Optional[Message]: + try: + completion = await API.chat(self.context, self.model.name) + return completion.choices[0].message + except (httpx.ReadTimeout, httpx.RequestError): + if not self.is_contextual: + await self._message_reaction("fail") + await UniMessage.text("Oops! 网络超时,请稍后重试").finish(reply_to=self.message_id) + await self._handle_rollback(by_error=True) + except RequestException as e: + if not self.is_contextual: + await UniMessage.text(str(e)).finish(reply_to=self.message_id) + await self._handle_rollback(by_error=True) + + def _extract_content_and_think(self, message: Message) -> tuple[str, str]: + thinking = message.reasoning_content + + if not thinking: + think_blocks = re.findall(r"(.*?)", message.content or "", flags=re.DOTALL) + thinking = "\n".join([block.strip() for block in think_blocks if block.strip()]) + + content = re.sub(r".*?", "", message.content or "", flags=re.DOTALL).strip() + + return content, thinking + + def _format_output(self, message: Message, with_thinking: bool) -> str: + content, thinking = self._extract_content_and_think(message) + + if with_thinking and content and thinking: + return ( + f"

{thinking}

{content}" + if self.is_to_pic + else f"{thinking}\n\n--------------------\n\n{content}" + ) + return content + + async def _send_response(self, message: Message) -> None: + output = self._format_output(message, ds_config.enable_send_thinking) + message.reasoning_content = None + + await self._message_reaction("done") + + if self.tts_model: + try: + output = self._format_output(message, False) + unimsg = UniMessage.audio(raw=await API.text_to_speach(output, self.tts_model.name)) + await unimsg.send() + except RequestException as e: + tts_logger("ERROR", f"TTS Response error: {e}, Use image or text instead") + output = self._format_output(message, ds_config.enable_send_thinking) + unimsg = ( + UniMessage.image(raw=await self.md_to_pic(output)) + if self.is_to_pic and callable(self.md_to_pic) + else UniMessage(output) + ) + await unimsg.send(reply_to=self.message_id) + elif self.is_to_pic and callable(self.md_to_pic): + unimsg = UniMessage.image(raw=await self.md_to_pic(output)) + await unimsg.send(reply_to=self.message_id) + else: + await UniMessage.text(output).send(reply_to=self.message_id) diff --git a/src/plugins/nonebot_plugin_deepseek/version.py b/src/plugins/nonebot_plugin_deepseek/version.py new file mode 100644 index 0000000..ec5fbad --- /dev/null +++ b/src/plugins/nonebot_plugin_deepseek/version.py @@ -0,0 +1,22 @@ +from importlib.metadata import metadata + +from nonebot.compat import PYDANTIC_V2 +from pydantic import BaseModel, ConfigDict + + +class Metadata(BaseModel): + name: str + version: str + summary: str + + if PYDANTIC_V2: + model_config = ConfigDict(extra="allow") + else: + + class Config: + extra = "allow" + + +__metadata__ = Metadata(**metadata("nonebot-plugin-deepseek").json) # type: ignore + +__version__ = __metadata__.version diff --git a/uv.lock b/uv.lock index 8c5d3f1..a7e55bd 100644 --- a/uv.lock +++ b/uv.lock @@ -158,6 +158,7 @@ name = "botfoochan" version = "0.5.10" source = { virtual = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "diro-py" }, { name = "matplotlib" }, { name = "nonebot-adapter-feishu" }, @@ -166,12 +167,13 @@ dependencies = [ { name = "nonebot-bison" }, { name = "nonebot-plugin-alconna" }, { name = "nonebot-plugin-apscheduler" }, - { name = "nonebot-plugin-deepseek" }, { name = "nonebot-plugin-filehost" }, { name = "nonebot-plugin-llmchat" }, + { name = "nonebot-plugin-localstore" }, { name = "nonebot-plugin-sentry" }, { name = "nonebot-plugin-shorturl" }, { name = "nonebot-plugin-treehelp" }, + { name = "nonebot-plugin-waiter" }, { name = "nonebot-plugin-zssm" }, { name = "nonebot2", extra = ["fastapi", "httpx", "websockets"] }, { name = "playwright" }, @@ -189,6 +191,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12.3,<5.0.0" }, { name = "diro-py", specifier = ">=0.1.0,<1.0.0" }, { name = "matplotlib", specifier = ">=3.8.0,<4.0.0" }, { name = "nonebot-adapter-feishu", specifier = ">=2.3.0,<3.0.0" }, @@ -197,12 +200,13 @@ requires-dist = [ { name = "nonebot-bison", specifier = ">=0.9.0,<1.0.0" }, { name = "nonebot-plugin-alconna", specifier = ">=0.38.0,<1.0.0" }, { name = "nonebot-plugin-apscheduler", specifier = ">=0.4.0,<1.0.0" }, - { name = "nonebot-plugin-deepseek", specifier = ">=0.1.0,<1.0.0" }, { name = "nonebot-plugin-filehost", specifier = ">=1.1.0,<2.0.0" }, { name = "nonebot-plugin-llmchat", specifier = ">=0.1.0,<1.0.0" }, + { name = "nonebot-plugin-localstore", specifier = ">=0.7.0,<1.0.0" }, { name = "nonebot-plugin-sentry", specifier = ">=2.0.0,<3.0.0" }, { name = "nonebot-plugin-shorturl", specifier = ">=0.4.0,<1.0.0" }, { name = "nonebot-plugin-treehelp", specifier = ">=0.3.0,<1.0.0" }, + { name = "nonebot-plugin-waiter", specifier = ">=0.2.0,<1.0.0" }, { name = "nonebot-plugin-zssm", specifier = ">=0.3.0,<1.0.0" }, { name = "nonebot2", extras = ["httpx", "fastapi", "websockets"], specifier = ">=2.1.0,<3.0.0" }, { name = "playwright", specifier = ">=1.38.0,<2.0.0" }, @@ -302,20 +306,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] -[[package]] -name = "cli-lite" -version = "0.11.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "arclet-alconna" }, - { name = "arclet-alconna-tools" }, - { name = "importlib-metadata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/1e/ff103a40e897e3103151659b53151281ed0d261d4c8b3fbe6a9f6f7a61b9/cli_lite-0.11.3.tar.gz", hash = "sha256:7911c6a2603b2289e72defbd3185a0deb81f926f909cc8bd15299593166c0e95", size = 7055, upload-time = "2025-08-25T07:42:15.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/37/c9bd8b8fd6d0ad105b0c6060ca4181fda9e9192eaa3fbad421dc73d0b577/cli_lite-0.11.3-py3-none-any.whl", hash = "sha256:93d3e68bc532a2c39505295b52f3adc8f9bc950f1683256ff3004234d595231d", size = 8247, upload-time = "2025-08-25T07:42:13.745Z" }, -] - [[package]] name = "click" version = "8.4.1" @@ -1661,23 +1651,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/3a/3742894d5c774ec9fcc786ef629a058e21760d22c0c7acdc9a7820b57e57/nonebot_plugin_datastore-1.3.1-py3-none-any.whl", hash = "sha256:3f6fa91e9fac8edd143b7213371307c40598faa3c2a5f20fbda1f3b686a4fa95", size = 26791, upload-time = "2025-08-13T15:03:18.244Z" }, ] -[[package]] -name = "nonebot-plugin-deepseek" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "cli-lite" }, - { name = "httpx", extra = ["http2"] }, - { name = "nonebot-plugin-alconna" }, - { name = "nonebot-plugin-localstore" }, - { name = "nonebot2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/14/5fd4d74053c9c858114a123bea2f325a8b6137210bc53ca3b756d73de18e/nonebot_plugin_deepseek-0.2.1.tar.gz", hash = "sha256:2f348276507174a7f4df108368b75f1286c61a6b8d44a342880fc343c3773a08", size = 27379, upload-time = "2026-05-04T08:45:13.502Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/a8/b07f673f8cbfc5571f69c6355644bd52e12de7675bcaa8e4c93310579fc0/nonebot_plugin_deepseek-0.2.1-py3-none-any.whl", hash = "sha256:3f184c0fe3cd8170d1ddbf04eecec7ca7e8f6b6df35f1788784b44edeed466bb", size = 37196, upload-time = "2026-05-04T08:45:12.228Z" }, -] - [[package]] name = "nonebot-plugin-filehost" version = "1.1.1"