entari-plugin-llm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- entari_plugin_llm/README.md +92 -0
- entari_plugin_llm/__init__.py +35 -0
- entari_plugin_llm/_callback.py +21 -0
- entari_plugin_llm/_jsondata.py +52 -0
- entari_plugin_llm/_types.py +35 -0
- entari_plugin_llm/config.py +105 -0
- entari_plugin_llm/exception.py +1 -0
- entari_plugin_llm/handlers/__init__.py +0 -0
- entari_plugin_llm/handlers/chat.py +53 -0
- entari_plugin_llm/handlers/check.py +36 -0
- entari_plugin_llm/handlers/command.py +188 -0
- entari_plugin_llm/handlers/manager.py +276 -0
- entari_plugin_llm/handlers/utils.py +60 -0
- entari_plugin_llm/json_output.py +56 -0
- entari_plugin_llm/log.py +20 -0
- entari_plugin_llm/model.py +65 -0
- entari_plugin_llm/service.py +311 -0
- entari_plugin_llm/tools/__init__.py +1 -0
- entari_plugin_llm/tools/builtins/image_vision.py +42 -0
- entari_plugin_llm/tools/builtins/webpage_processor.py +47 -0
- entari_plugin_llm/tools/event.py +88 -0
- entari_plugin_llm-0.1.0.dist-info/METADATA +109 -0
- entari_plugin_llm-0.1.0.dist-info/RECORD +26 -0
- entari_plugin_llm-0.1.0.dist-info/WHEEL +4 -0
- entari_plugin_llm-0.1.0.dist-info/entry_points.txt +4 -0
- entari_plugin_llm-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# entari-plugin-llm
|
|
2
|
+
|
|
3
|
+
entari-plugin-llm 是一个用于 Entari 框架的 LLM(大语言模型)插件,提供基于 litellm 的对话能力、函数调用(Tool Call)支持、会话管理以及若干内置实用工具(如图像识别、网页处理等)。
|
|
4
|
+
|
|
5
|
+
插件目标是为基于 Arclet Entari 的机器人/服务提供一个可配置、可拓展的 LLM 工具箱,便于在对话中调用函数以完成复杂任务,并支持结构化 JSON 输出、视觉识别等能力。
|
|
6
|
+
|
|
7
|
+
主要特性
|
|
8
|
+
- 基于 litellm 的聊天能力(支持流式与非流式)。
|
|
9
|
+
- 支持“函数调用”机制(Tool Call),可以把插件内的订阅函数自动注册为 LLM 可调用的工具。
|
|
10
|
+
- 会话与上下文持久化(使用 `entari-plugin-database` 提供的数据库模型)。
|
|
11
|
+
- 支持视觉(image)输入的模型调用(当模型支持 vision 时)。
|
|
12
|
+
- 可配置的模型/提示/工具调用策略,通过 `Config` 加载与热重载(ConfigReload)。
|
|
13
|
+
|
|
14
|
+
要求
|
|
15
|
+
- Python >= 3.10
|
|
16
|
+
- 依赖见 `pyproject.toml` 中的 `dependencies`(推荐使用 pdm 或 uv 等现代包管理器)。
|
|
17
|
+
|
|
18
|
+
## 快速开始
|
|
19
|
+
|
|
20
|
+
1. 克隆仓库:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
git clone https://github.com/ArcletProject/entari-plugin-llm.git
|
|
24
|
+
cd entari-plugin-llm
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
2. 安装依赖(使用 pdm,或使用 pip 在虚拟环境中安装):
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
pdm sync
|
|
31
|
+
# 或者使用 uv:
|
|
32
|
+
uv sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. 运行本地示例(运行 Entari 应用):
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
python main.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
说明:`main.py` 通过 `Entari.load("")` 加载当前目录下的 Entari 配置并启动服务 —— 在实际部署时请提供合适的配置文件与环境变量(例如模型的 API key、base_url 等)。
|
|
42
|
+
|
|
43
|
+
## 基本用法(示例)
|
|
44
|
+
|
|
45
|
+
作为插件使用时,包会在导入时通过 `metadata()` 注册插件信息,并在运行时加载配置、工具与服务。你可以在代码中直接引用导出的服务:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from entari_plugin_llm import llm
|
|
49
|
+
|
|
50
|
+
# 在异步上下文中调用
|
|
51
|
+
resp = await llm.generate("Hello world")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 工具与函数调用(Tool)
|
|
55
|
+
- 插件将符合 arclet.letoderea 订阅器规范的函数自动注册为可被 LLM 调用的工具。
|
|
56
|
+
- 工具的参数与文档由函数的 docstring 与类型注解自动生成 JSON Schema,以便 LLM 在函数调用时进行参数填充。
|
|
57
|
+
|
|
58
|
+
### 配置与热重载
|
|
59
|
+
|
|
60
|
+
插件使用 `Config` 对象管理模型、提示词、上下文长度以及工具调用的最大循环步数。修改 Entari 插件配置并触发 `ConfigReload` 事件可以热重载这些设置。
|
|
61
|
+
|
|
62
|
+
## 项目结构(重要文件与目录)
|
|
63
|
+
|
|
64
|
+
- `src/entari_plugin_llm/` - 插件实现代码
|
|
65
|
+
- `__init__.py` - 插件元信息与自动注册
|
|
66
|
+
- `service.py` - LLM 服务实现(封装了 litellm 的调用、工具调用处理、vision 等)
|
|
67
|
+
- `model.py` - 数据库 ORM 模型(会话与上下文)
|
|
68
|
+
- `handlers/` - Entari 事件处理器(chat、command、check 等)
|
|
69
|
+
- `tools/` - 插件提供的工具注册逻辑与内置工具(如 image_vision、webpage_processor)
|
|
70
|
+
- `main.py` - 一个简单的启动示例
|
|
71
|
+
- `pyproject.toml` - 项目与依赖配置
|
|
72
|
+
|
|
73
|
+
## 开发与调试
|
|
74
|
+
|
|
75
|
+
在开发过程中你可以编辑 `entari.yml` 或插件配置,并使用 Entari 的配置热重载来应用更改。
|
|
76
|
+
|
|
77
|
+
## 贡献
|
|
78
|
+
|
|
79
|
+
欢迎提交 issue 与 PR。请遵循仓库的编码风格与测试规范,尽量在 PR 中包含说明与复现步骤。
|
|
80
|
+
|
|
81
|
+
## 许可证
|
|
82
|
+
|
|
83
|
+
本项目遵循 MIT 许可证(见 `pyproject.toml` 中的 license 字段)。
|
|
84
|
+
|
|
85
|
+
## 联系方式
|
|
86
|
+
|
|
87
|
+
作者: RF-Tar-Railt, KomoriDev(详见 `src/entari_plugin_llm/__init__.py` 中的作者信息)
|
|
88
|
+
|
|
89
|
+
## 更多信息
|
|
90
|
+
|
|
91
|
+
请参阅 `pyproject.toml` 中的依赖与可选依赖(如浏览器、Google provider 等)以获取额外功能支持。
|
|
92
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from arclet.entari import declare_static, metadata, plugin
|
|
2
|
+
|
|
3
|
+
from .config import Config, _conf
|
|
4
|
+
from .log import _suppress_litellm_logging
|
|
5
|
+
from .tools import LLMToolEvent as LLMToolEvent
|
|
6
|
+
|
|
7
|
+
metadata(
|
|
8
|
+
name="LLM 工具箱",
|
|
9
|
+
author=[
|
|
10
|
+
{"name": "RF-Tar-Railt", "email": "rf_tar_railt@qq.com"},
|
|
11
|
+
{"name": "KomoriDev", "email": "mute231010@gmail.com"},
|
|
12
|
+
],
|
|
13
|
+
version="0.1.0",
|
|
14
|
+
description="一个通用的 LLM 工具箱插件,提供了丰富的工具和模型配置选项,支持多种 LLM 模型,并且可以轻松集成到各种应用场景中。",
|
|
15
|
+
urls={
|
|
16
|
+
"homepage": "https://github.com/ArcletProject/entari-plugin-llm",
|
|
17
|
+
},
|
|
18
|
+
config=Config,
|
|
19
|
+
readme="README.md",
|
|
20
|
+
)
|
|
21
|
+
declare_static()
|
|
22
|
+
_suppress_litellm_logging()
|
|
23
|
+
|
|
24
|
+
for tool in _conf.tools:
|
|
25
|
+
plugin.load_plugin(tool)
|
|
26
|
+
|
|
27
|
+
from .handlers import chat as chat
|
|
28
|
+
from .handlers import check as check
|
|
29
|
+
from .handlers import command as command
|
|
30
|
+
from .service import llm as llm
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"llm",
|
|
34
|
+
"LLMToolEvent",
|
|
35
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from litellm.integrations.custom_logger import CustomLogger
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .service import LLMService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenUsageHandler(CustomLogger):
|
|
10
|
+
def __init__(self, service: "LLMService"):
|
|
11
|
+
self.service = service
|
|
12
|
+
|
|
13
|
+
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
14
|
+
if "usage" in response_obj:
|
|
15
|
+
self.service.total_tokens += response_obj["usage"].get("total_tokens", 0)
|
|
16
|
+
self.service.total_calls += 1
|
|
17
|
+
|
|
18
|
+
async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time):
|
|
19
|
+
if "usage" in response_obj:
|
|
20
|
+
self.service.total_tokens += response_obj["usage"].get("total_tokens", 0)
|
|
21
|
+
self.service.total_calls += 1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from arclet.entari import local_data
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class LLMState:
|
|
11
|
+
default_model: str | None = None
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_dict(cls, data: dict[str, Any]) -> "LLMState":
|
|
15
|
+
value = data.get("default_model")
|
|
16
|
+
default_model = value if isinstance(value, str) and value else None
|
|
17
|
+
return cls(default_model=default_model)
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
return asdict(self)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _state_path() -> Path:
|
|
24
|
+
return local_data.get_data_file("entari_plugin_llm", "state.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_state() -> LLMState:
|
|
28
|
+
path = _state_path()
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return LLMState()
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, json.JSONDecodeError):
|
|
34
|
+
return LLMState()
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
return LLMState()
|
|
37
|
+
return LLMState.from_dict(data)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_state(data: LLMState) -> None:
|
|
41
|
+
path = _state_path()
|
|
42
|
+
path.write_text(json.dumps(data.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_default_model() -> str | None:
|
|
46
|
+
return _read_state().default_model
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_default_model(model_name: str | None) -> None:
|
|
50
|
+
state = _read_state()
|
|
51
|
+
state.default_model = model_name if model_name else None
|
|
52
|
+
_write_state(state)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, Literal, TypeAlias, TypedDict
|
|
2
|
+
|
|
3
|
+
from typing_extensions import NotRequired
|
|
4
|
+
|
|
5
|
+
JSON_VALUE: TypeAlias = str | int | float | bool | None
|
|
6
|
+
JSON_TYPE: TypeAlias = dict[str, "JSON_TYPE"] | list["JSON_TYPE"] | JSON_VALUE
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SystemMessage(TypedDict):
|
|
10
|
+
role: Literal["system"]
|
|
11
|
+
content: str
|
|
12
|
+
name: NotRequired[str | None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UserMessage(TypedDict):
|
|
16
|
+
role: Literal["user"]
|
|
17
|
+
content: str | list[dict[str, Any]]
|
|
18
|
+
name: NotRequired[str | None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AssistantMessage(TypedDict):
|
|
22
|
+
role: Literal["assistant"]
|
|
23
|
+
content: str | None
|
|
24
|
+
tool_calls: NotRequired[list[dict[str, Any]] | None]
|
|
25
|
+
reasoning_content: NotRequired[str | None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolMessage(TypedDict):
|
|
29
|
+
role: Literal["tool"]
|
|
30
|
+
content: str
|
|
31
|
+
tool_call_id: str
|
|
32
|
+
name: NotRequired[str | None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Message: TypeAlias = SystemMessage | UserMessage | AssistantMessage | ToolMessage
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from arclet.entari import BasicConfModel, plugin_config
|
|
4
|
+
from arclet.entari.config import model_field
|
|
5
|
+
|
|
6
|
+
from ._jsondata import get_default_model
|
|
7
|
+
from .exception import ModelNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScopedModel(BasicConfModel):
|
|
11
|
+
name: str
|
|
12
|
+
"""用于 OpenAI API 的模型"""
|
|
13
|
+
alias: str | None = None
|
|
14
|
+
"""模型的别名"""
|
|
15
|
+
api_key: str | None = None
|
|
16
|
+
"""用于使用 OpenAI API 进行身份验证的 API 密钥。如果未设置,则回退到全局 api_key"""
|
|
17
|
+
base_url: str = "https://api.openai.com/v1"
|
|
18
|
+
"""OpenAI API 的接口地址。如果未设置,则回退到全局 base_url"""
|
|
19
|
+
prompt: str = ""
|
|
20
|
+
"""该模型使用的提示词。如果未设置,则回退到全局 prompt"""
|
|
21
|
+
extra: dict[str, Any] = model_field(default_factory=dict)
|
|
22
|
+
"""传递给 LLM API 调用的额外参数"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Config(BasicConfModel, extra="allow"):
|
|
26
|
+
api_key: str | None = None
|
|
27
|
+
"""用于使用 OpenAI API 进行身份验证的全局 API 密钥。用作没有特定键的模型的后备"""
|
|
28
|
+
base_url: str = "https://api.openai.com/v1"
|
|
29
|
+
"""OpenAI API 的全局接口地址。用作没有特定接口地址的模型的后备"""
|
|
30
|
+
prompt: str = ""
|
|
31
|
+
"""全局提示词。用作没有特定提示词的模型的后备"""
|
|
32
|
+
models: list[ScopedModel] = model_field(default_factory=list)
|
|
33
|
+
"""配置模型及其各自设置的列表"""
|
|
34
|
+
toolcall_max_steps: int = 8
|
|
35
|
+
"""单个会话中工具调用的最大步骤数"""
|
|
36
|
+
context_length: int = 50
|
|
37
|
+
"""上下文长度"""
|
|
38
|
+
tools: dict[str, dict[str, Any]] = model_field(default_factory=dict)
|
|
39
|
+
"""工具"""
|
|
40
|
+
|
|
41
|
+
def _reload_tools(self):
|
|
42
|
+
loaded_tools: dict[str, dict[str, Any]] = {}
|
|
43
|
+
|
|
44
|
+
for key, value in self.tools.items():
|
|
45
|
+
if key.startswith("$"):
|
|
46
|
+
loaded_tools[key] = value
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
tool_config = dict(value)
|
|
50
|
+
new_key = key
|
|
51
|
+
|
|
52
|
+
if key.startswith("~"):
|
|
53
|
+
new_key = key[1:]
|
|
54
|
+
if "$disable" not in tool_config or isinstance(tool_config["$disable"], bool):
|
|
55
|
+
tool_config["$disable"] = True
|
|
56
|
+
elif key.startswith("?"):
|
|
57
|
+
new_key = key[1:]
|
|
58
|
+
tool_config["$optional"] = True
|
|
59
|
+
|
|
60
|
+
if key.startswith("::"):
|
|
61
|
+
new_key = new_key.replace("::", "entari_plugin_llm.tools.builtins.")
|
|
62
|
+
|
|
63
|
+
if tool_config.get("$disable"):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
loaded_tools[new_key] = tool_config
|
|
67
|
+
|
|
68
|
+
self.tools = loaded_tools
|
|
69
|
+
|
|
70
|
+
def __post_init__(self):
|
|
71
|
+
self._reload_tools()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_conf = plugin_config(Config)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_model_config(model_name: str | None = None) -> ScopedModel:
|
|
78
|
+
if model_name is None:
|
|
79
|
+
if not _conf.models:
|
|
80
|
+
raise ModelNotFoundError("No models configured.")
|
|
81
|
+
|
|
82
|
+
model_name = get_default_model()
|
|
83
|
+
|
|
84
|
+
for model in _conf.models:
|
|
85
|
+
if model.name == model_name or model.alias == model_name:
|
|
86
|
+
model_cp = ScopedModel(
|
|
87
|
+
name=model.name,
|
|
88
|
+
alias=model.alias,
|
|
89
|
+
api_key=model.api_key,
|
|
90
|
+
base_url=model.base_url,
|
|
91
|
+
prompt=model.prompt,
|
|
92
|
+
extra=model.extra,
|
|
93
|
+
)
|
|
94
|
+
if not model.api_key and _conf.api_key:
|
|
95
|
+
model_cp.api_key = _conf.api_key
|
|
96
|
+
if model.base_url == "https://api.openai.com/v1" and _conf.base_url != "https://api.openai.com/v1":
|
|
97
|
+
model_cp.base_url = _conf.base_url
|
|
98
|
+
if not model.prompt and _conf.prompt:
|
|
99
|
+
model_cp.prompt = _conf.prompt
|
|
100
|
+
return model_cp
|
|
101
|
+
raise ModelNotFoundError(f"Model {model_name} not found in config.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_model_list() -> set[str]:
|
|
105
|
+
return {m.name for m in _conf.models} | {m.alias for m in _conf.models if m.alias}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class ModelNotFoundError(Exception): ...
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
|
|
3
|
+
from arclet.entari import MessageChain, MessageCreatedEvent, Session, filter_
|
|
4
|
+
from arclet.entari.config import config_model_validate
|
|
5
|
+
from arclet.entari.event.config import ConfigReload
|
|
6
|
+
from arclet.entari.event.send import SendResponse
|
|
7
|
+
from arclet.letoderea import BLOCK, on
|
|
8
|
+
from arclet.letoderea.context import Contexts
|
|
9
|
+
|
|
10
|
+
from ..config import Config, _conf
|
|
11
|
+
from ..exception import ModelNotFoundError
|
|
12
|
+
from .manager import LLMSessionManager
|
|
13
|
+
|
|
14
|
+
RECORD = deque(maxlen=16)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@on(SendResponse)
|
|
18
|
+
async def _record(event: SendResponse):
|
|
19
|
+
if event.result and event.session:
|
|
20
|
+
RECORD.append(event.session.event.sn)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@on(MessageCreatedEvent, priority=1000).if_(filter_.to_me)
|
|
24
|
+
async def run_conversation(session: Session, ctx: Contexts):
|
|
25
|
+
if session.event.sn in RECORD:
|
|
26
|
+
return BLOCK
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
answer = await LLMSessionManager.chat(
|
|
30
|
+
session.elements,
|
|
31
|
+
session=session,
|
|
32
|
+
ctx=ctx,
|
|
33
|
+
)
|
|
34
|
+
if answer != "[END_OF_RESPONSE]":
|
|
35
|
+
await session.send(answer)
|
|
36
|
+
except ModelNotFoundError as e:
|
|
37
|
+
await session.send(MessageChain(str(e)))
|
|
38
|
+
except Exception as e:
|
|
39
|
+
await session.send(MessageChain(str(e)))
|
|
40
|
+
return BLOCK
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@on(ConfigReload)
|
|
44
|
+
async def reload_config(event: ConfigReload):
|
|
45
|
+
if event.scope != "plugin":
|
|
46
|
+
return
|
|
47
|
+
if event.key not in ("entari_plugin_llm", "llm"):
|
|
48
|
+
return
|
|
49
|
+
new_conf = config_model_validate(Config, event.value)
|
|
50
|
+
_conf.models = new_conf.models
|
|
51
|
+
_conf.prompt = new_conf.prompt
|
|
52
|
+
_conf.context_length = new_conf.context_length
|
|
53
|
+
_conf.toolcall_max_steps = new_conf.toolcall_max_steps
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from arclet.entari.event.lifespan import Ready
|
|
2
|
+
from arclet.letoderea import on
|
|
3
|
+
|
|
4
|
+
from .._jsondata import get_default_model, set_default_model
|
|
5
|
+
from ..config import _conf
|
|
6
|
+
from ..log import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@on(Ready)
|
|
10
|
+
async def _():
|
|
11
|
+
if not _conf.models:
|
|
12
|
+
set_default_model(None)
|
|
13
|
+
logger.warning("未配置任何模型,已清空本地默认模型配置")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
first_model = _conf.models[0].name
|
|
17
|
+
default_model = get_default_model()
|
|
18
|
+
if not default_model:
|
|
19
|
+
set_default_model(first_model)
|
|
20
|
+
logger.info(f"未检测到本地默认模型,已设置为首个模型: {first_model}")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
matched = next(
|
|
24
|
+
(m for m in _conf.models if m.name == default_model or m.alias == default_model),
|
|
25
|
+
None,
|
|
26
|
+
)
|
|
27
|
+
if matched is None:
|
|
28
|
+
set_default_model(first_model)
|
|
29
|
+
logger.warning(
|
|
30
|
+
f"本地默认模型不存在于当前配置: {default_model},已重置为: {first_model}",
|
|
31
|
+
)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if matched.name != default_model:
|
|
35
|
+
set_default_model(matched.name)
|
|
36
|
+
logger.info(f"已将本地默认模型标准化为模型名: {matched.name}")
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from arclet.alconna import Alconna, Args, MultiVar, Option, Subcommand, store_true
|
|
2
|
+
from arclet.entari import MessageChain, Session, command
|
|
3
|
+
from arclet.entari.const import ITEM_MESSAGE_REPLY
|
|
4
|
+
from arclet.letoderea import BLOCK, Contexts
|
|
5
|
+
|
|
6
|
+
from .._jsondata import set_default_model
|
|
7
|
+
from ..config import get_model_config, get_model_list
|
|
8
|
+
from ..exception import ModelNotFoundError
|
|
9
|
+
from .manager import LLMSessionManager
|
|
10
|
+
from .utils import render_model_list, render_session_list, select_session
|
|
11
|
+
|
|
12
|
+
llm_alc = Alconna(
|
|
13
|
+
"llm",
|
|
14
|
+
Args["content?#内容", MultiVar(str)],
|
|
15
|
+
Option("-m|--model", Args["model?#模型名称", str], help_text="指定模型"),
|
|
16
|
+
Option(
|
|
17
|
+
"-n|--new",
|
|
18
|
+
dest="new_opt",
|
|
19
|
+
default=False,
|
|
20
|
+
action=store_true,
|
|
21
|
+
help_text="创建新会话",
|
|
22
|
+
),
|
|
23
|
+
Subcommand("new", dest="new_cmd", help_text="创建新会话"),
|
|
24
|
+
Subcommand("switch", Args["session_id?#会话ID", str], help_text="切换会话"),
|
|
25
|
+
Subcommand("delete", Args["session_id?#会话ID", str], help_text="删除会话"),
|
|
26
|
+
Subcommand(
|
|
27
|
+
"session",
|
|
28
|
+
Option("-l|--list", help_text="查看会话列表"),
|
|
29
|
+
help_text="查看当前会话信息",
|
|
30
|
+
),
|
|
31
|
+
Subcommand(
|
|
32
|
+
"model",
|
|
33
|
+
Args["model?#模型名称", str],
|
|
34
|
+
Option("-l|--list", help_text="查看模型列表"),
|
|
35
|
+
help_text="查看当前模型信息",
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
llm_alc.shortcut("ai", {"command": "llm", "fuzzy": True, "prefix": True})
|
|
40
|
+
|
|
41
|
+
llm_disp = command.mount(llm_alc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@llm_disp.handle(priority=25)
|
|
45
|
+
async def _(
|
|
46
|
+
ctx: Contexts,
|
|
47
|
+
session: Session,
|
|
48
|
+
content: command.Match[MessageChain],
|
|
49
|
+
new_opt: command.Query[bool] = command.Query("new_opt.value"),
|
|
50
|
+
model: command.Query[str] = command.Query("model.model"),
|
|
51
|
+
):
|
|
52
|
+
user_prompt = MessageChain([])
|
|
53
|
+
|
|
54
|
+
if reply := ctx.get(ITEM_MESSAGE_REPLY):
|
|
55
|
+
user_prompt += reply.origin.message
|
|
56
|
+
|
|
57
|
+
if content.available:
|
|
58
|
+
user_prompt += content.result
|
|
59
|
+
|
|
60
|
+
if not user_prompt:
|
|
61
|
+
resp = await session.prompt("需要我为你做些什么?")
|
|
62
|
+
if not resp:
|
|
63
|
+
await session.send("等待超时")
|
|
64
|
+
return BLOCK
|
|
65
|
+
user_prompt = resp
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
answer = await LLMSessionManager.chat(
|
|
69
|
+
user_prompt,
|
|
70
|
+
session=session,
|
|
71
|
+
ctx=ctx,
|
|
72
|
+
model=model.result if model.available else None,
|
|
73
|
+
new=new_opt.result,
|
|
74
|
+
)
|
|
75
|
+
if answer != "[END_OF_RESPONSE]":
|
|
76
|
+
await session.send(answer)
|
|
77
|
+
except ModelNotFoundError as e:
|
|
78
|
+
await session.send(MessageChain(str(e)))
|
|
79
|
+
except Exception as e:
|
|
80
|
+
await session.send(MessageChain(str(e)))
|
|
81
|
+
|
|
82
|
+
return BLOCK
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@llm_disp.assign("new_cmd")
|
|
86
|
+
async def _(session: Session):
|
|
87
|
+
new_session = await LLMSessionManager.create_new_session(f"{session.account.platform}@{session.user.id}")
|
|
88
|
+
await session.send(f"以创建并切换到新会话\n会话ID: {new_session.session_id}")
|
|
89
|
+
return BLOCK
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@llm_disp.assign("switch")
|
|
93
|
+
async def _(session: Session, session_id: command.Match[str]):
|
|
94
|
+
if not session_id.available:
|
|
95
|
+
selected = await select_session(session)
|
|
96
|
+
if selected is None:
|
|
97
|
+
return BLOCK
|
|
98
|
+
|
|
99
|
+
session_id.result = selected
|
|
100
|
+
|
|
101
|
+
switched = await LLMSessionManager.switch(f"{session.account.platform}@{session.user.id}", session_id.result)
|
|
102
|
+
await session.send("切换成功" if switched else "未找到对应会话")
|
|
103
|
+
return BLOCK
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@llm_disp.assign("delete")
|
|
107
|
+
async def _(session: Session, session_id: command.Match[str]):
|
|
108
|
+
if not session_id.available:
|
|
109
|
+
selected = await select_session(session)
|
|
110
|
+
if selected is None:
|
|
111
|
+
return BLOCK
|
|
112
|
+
|
|
113
|
+
session_id.result = selected
|
|
114
|
+
info = await LLMSessionManager.get_current_session_info(f"{session.account.platform}@{session.user.id}")
|
|
115
|
+
deleted = await LLMSessionManager.delete(f"{session.account.platform}@{session.user.id}", session_id.result)
|
|
116
|
+
if deleted:
|
|
117
|
+
rows = await LLMSessionManager.list_sessions(f"{session.account.platform}@{session.user.id}")
|
|
118
|
+
if not rows:
|
|
119
|
+
await LLMSessionManager.create_new_session(f"{session.account.platform}@{session.user.id}")
|
|
120
|
+
await session.send("删除成功,已自动创建新会话")
|
|
121
|
+
elif info and info["session_id"] == session_id.result:
|
|
122
|
+
switched = await LLMSessionManager.switch(
|
|
123
|
+
f"{session.account.platform}@{session.user.id}", rows[0].session_id
|
|
124
|
+
)
|
|
125
|
+
await session.send("删除成功,已切换到最近的会话" if switched else "删除成功,但未找到对应会话")
|
|
126
|
+
else:
|
|
127
|
+
await session.send("删除成功,当前会话列表:\n" + render_session_list(rows))
|
|
128
|
+
else:
|
|
129
|
+
await session.send("未找到对应会话")
|
|
130
|
+
return BLOCK
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@llm_disp.assign("session", priority=20)
|
|
134
|
+
async def _(session: Session):
|
|
135
|
+
info = await LLMSessionManager.get_current_session_info(f"{session.account.platform}@{session.user.id}")
|
|
136
|
+
if info is None:
|
|
137
|
+
await session.send("当前没有活动会话")
|
|
138
|
+
return BLOCK
|
|
139
|
+
|
|
140
|
+
created_at = info["created_at"].strftime("%Y-%m-%d %H:%M:%S")
|
|
141
|
+
await session.send(
|
|
142
|
+
"\n".join(
|
|
143
|
+
[
|
|
144
|
+
f"会话ID: {info['session_id']}",
|
|
145
|
+
f"话题: {info['topic']}",
|
|
146
|
+
f"消息数: {info['message_count']}",
|
|
147
|
+
f"累计 Token: {info['total_tokens']}",
|
|
148
|
+
f"创建时间: {created_at}",
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return BLOCK
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@llm_disp.assign("session.list")
|
|
156
|
+
async def _(session: Session):
|
|
157
|
+
rows = await LLMSessionManager.list_sessions(f"{session.account.platform}@{session.user.id}")
|
|
158
|
+
|
|
159
|
+
if not rows:
|
|
160
|
+
await session.send("暂无会话")
|
|
161
|
+
return BLOCK
|
|
162
|
+
|
|
163
|
+
await session.send(render_session_list(rows))
|
|
164
|
+
return BLOCK
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@llm_disp.assign("model", priority=20)
|
|
168
|
+
async def _(session: Session, model: command.Match[str]):
|
|
169
|
+
if model.available:
|
|
170
|
+
if model.result not in get_model_list():
|
|
171
|
+
await session.send(render_model_list())
|
|
172
|
+
return BLOCK
|
|
173
|
+
|
|
174
|
+
conf = get_model_config(model.result)
|
|
175
|
+
set_default_model(conf.name)
|
|
176
|
+
|
|
177
|
+
await session.send(f"已切换默认模型: {conf.name}")
|
|
178
|
+
return BLOCK
|
|
179
|
+
|
|
180
|
+
conf = get_model_config()
|
|
181
|
+
await session.send(render_model_list())
|
|
182
|
+
return BLOCK
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@llm_disp.assign("model.list")
|
|
186
|
+
async def _(session: Session):
|
|
187
|
+
await session.send(render_model_list())
|
|
188
|
+
return BLOCK
|