IlinaEngine 0.8.1__tar.gz
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.
- ilinaengine-0.8.1/.gitignore +219 -0
- ilinaengine-0.8.1/IlinaEngine/__init__.py +2 -0
- ilinaengine-0.8.1/IlinaEngine/_config_models.py +32 -0
- ilinaengine-0.8.1/IlinaEngine/_ilina_message.py +25 -0
- ilinaengine-0.8.1/IlinaEngine/call_openai.py +222 -0
- ilinaengine-0.8.1/IlinaEngine/engine.py +147 -0
- ilinaengine-0.8.1/IlinaEngine/exceptions.py +23 -0
- ilinaengine-0.8.1/IlinaEngine/sync_mcp.py +173 -0
- ilinaengine-0.8.1/IlinaEngine/sysprompt.py +19 -0
- ilinaengine-0.8.1/IlinaEngine/tools.py +72 -0
- ilinaengine-0.8.1/IlinaEngine/tree.py +344 -0
- ilinaengine-0.8.1/IlinaEngine/type.py +8 -0
- ilinaengine-0.8.1/LICENSE +21 -0
- ilinaengine-0.8.1/PKG-INFO +189 -0
- ilinaengine-0.8.1/README.md +154 -0
- ilinaengine-0.8.1/docs/IlinaMessage.md +109 -0
- ilinaengine-0.8.1/docs/config.md +44 -0
- ilinaengine-0.8.1/docs/engine.md +106 -0
- ilinaengine-0.8.1/docs/node.md +26 -0
- ilinaengine-0.8.1/docs/system_prompt_replace.md +5 -0
- ilinaengine-0.8.1/pyproject.toml +26 -0
- ilinaengine-0.8.1/requirements.txt +4 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
.obsidian
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[codz]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py.cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
# Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# UV
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# uv.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
# poetry.lock
|
|
110
|
+
# poetry.toml
|
|
111
|
+
|
|
112
|
+
# pdm
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
114
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
115
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
116
|
+
# pdm.lock
|
|
117
|
+
# pdm.toml
|
|
118
|
+
.pdm-python
|
|
119
|
+
.pdm-build/
|
|
120
|
+
|
|
121
|
+
# pixi
|
|
122
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
123
|
+
# pixi.lock
|
|
124
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
125
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
126
|
+
.pixi
|
|
127
|
+
|
|
128
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
129
|
+
__pypackages__/
|
|
130
|
+
|
|
131
|
+
# Celery stuff
|
|
132
|
+
celerybeat-schedule
|
|
133
|
+
celerybeat.pid
|
|
134
|
+
|
|
135
|
+
# Redis
|
|
136
|
+
*.rdb
|
|
137
|
+
*.aof
|
|
138
|
+
*.pid
|
|
139
|
+
|
|
140
|
+
# RabbitMQ
|
|
141
|
+
mnesia/
|
|
142
|
+
rabbitmq/
|
|
143
|
+
rabbitmq-data/
|
|
144
|
+
|
|
145
|
+
# ActiveMQ
|
|
146
|
+
activemq-data/
|
|
147
|
+
|
|
148
|
+
# SageMath parsed files
|
|
149
|
+
*.sage.py
|
|
150
|
+
|
|
151
|
+
# Environments
|
|
152
|
+
.env
|
|
153
|
+
.envrc
|
|
154
|
+
.venv
|
|
155
|
+
env/
|
|
156
|
+
venv/
|
|
157
|
+
ENV/
|
|
158
|
+
env.bak/
|
|
159
|
+
venv.bak/
|
|
160
|
+
|
|
161
|
+
# Spyder project settings
|
|
162
|
+
.spyderproject
|
|
163
|
+
.spyproject
|
|
164
|
+
|
|
165
|
+
# Rope project settings
|
|
166
|
+
.ropeproject
|
|
167
|
+
|
|
168
|
+
# mkdocs documentation
|
|
169
|
+
/site
|
|
170
|
+
|
|
171
|
+
# mypy
|
|
172
|
+
.mypy_cache/
|
|
173
|
+
.dmypy.json
|
|
174
|
+
dmypy.json
|
|
175
|
+
|
|
176
|
+
# Pyre type checker
|
|
177
|
+
.pyre/
|
|
178
|
+
|
|
179
|
+
# pytype static type analyzer
|
|
180
|
+
.pytype/
|
|
181
|
+
|
|
182
|
+
# Cython debug symbols
|
|
183
|
+
cython_debug/
|
|
184
|
+
|
|
185
|
+
# PyCharm
|
|
186
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
187
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
188
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
189
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
190
|
+
# .idea/
|
|
191
|
+
|
|
192
|
+
# Abstra
|
|
193
|
+
# Abstra is an AI-powered process automation framework.
|
|
194
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
195
|
+
# Learn more at https://abstra.io/docs
|
|
196
|
+
.abstra/
|
|
197
|
+
|
|
198
|
+
# Visual Studio Code
|
|
199
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
200
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
201
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
202
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
203
|
+
# .vscode/
|
|
204
|
+
# Temporary file for partial code execution
|
|
205
|
+
tempCodeRunnerFile.py
|
|
206
|
+
|
|
207
|
+
# Ruff stuff:
|
|
208
|
+
.ruff_cache/
|
|
209
|
+
|
|
210
|
+
# PyPI configuration file
|
|
211
|
+
.pypirc
|
|
212
|
+
|
|
213
|
+
# Marimo
|
|
214
|
+
marimo/_static/
|
|
215
|
+
marimo/_lsp/
|
|
216
|
+
__marimo__/
|
|
217
|
+
|
|
218
|
+
# Streamlit
|
|
219
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
class IlinaConfig(BaseModel):
|
|
4
|
+
""" 文件夹下的 .ilinaconfig 内的数据模型 """
|
|
5
|
+
workpath: str|None = None
|
|
6
|
+
open_or_alarm: bool = False # 如果是 True 倾向于打开文件,如果是 False 倾向于发送通知
|
|
7
|
+
|
|
8
|
+
class MCPConfig(BaseModel):
|
|
9
|
+
command: str
|
|
10
|
+
args: list[str]
|
|
11
|
+
|
|
12
|
+
class ModelConfig(BaseModel):
|
|
13
|
+
base_url: str
|
|
14
|
+
api_key: str
|
|
15
|
+
model_name: str
|
|
16
|
+
|
|
17
|
+
class AIConfig(BaseModel):
|
|
18
|
+
""" 模型配置 """
|
|
19
|
+
main_model: ModelConfig = ModelConfig(
|
|
20
|
+
base_url='http://localhost:11434/v1/',
|
|
21
|
+
api_key='sk-xxx',
|
|
22
|
+
model_name='qwen3-vl:4b'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
sub_model: ModelConfig = ModelConfig(
|
|
26
|
+
base_url='http://localhost:11434/v1/',
|
|
27
|
+
api_key='sk-xxx',
|
|
28
|
+
model_name='qwen3-vl:4b'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
mcps: dict[str, MCPConfig] = {}
|
|
32
|
+
default_system_prompt_template: str = '当前工作目录为 {{workpath}},当你编辑文件之后,你应该{{open_or_alarm}}'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Ilina Message
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
class IlinaToolDefinition(BaseModel):
|
|
6
|
+
""" 工具定义 """
|
|
7
|
+
name: str # 工具名称
|
|
8
|
+
description: str # 工具说明
|
|
9
|
+
arguments: dict[str, object] # 参数的 JSON Schema
|
|
10
|
+
|
|
11
|
+
class IlinaToolCall(BaseModel):
|
|
12
|
+
""" 工具调用 """
|
|
13
|
+
name: str = ''
|
|
14
|
+
arguments: str = ''
|
|
15
|
+
tool_call_id: str = ''
|
|
16
|
+
|
|
17
|
+
class IlinaMessage(BaseModel):
|
|
18
|
+
""" 对话消息 """
|
|
19
|
+
role: Literal['user', 'assistant', 'system', 'tool', 'error']
|
|
20
|
+
content: str = ''
|
|
21
|
+
reasoning_content: str = '' # 仅在 assistant 中使用
|
|
22
|
+
tool_calls: list[IlinaToolCall] = [] # 仅在 assistant 中使用
|
|
23
|
+
tool_call_id: str = '' # 仅在 tool 中使用
|
|
24
|
+
tool_name: str = '' # 保存工具名,仅在 tool 中使用
|
|
25
|
+
# tool_readable_content: str = '' # 返回一个人类可读的消息,如果指定了这一项,那么渲染时建议用这个替换content
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# 调用 API
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Generator
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from FovesConfig import ConfigLoader
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# from tools import Toolset
|
|
11
|
+
from .tree import Node
|
|
12
|
+
from .sync_mcp import MCPLoader
|
|
13
|
+
from ._config_models import AIConfig
|
|
14
|
+
from ._ilina_message import IlinaMessage, IlinaToolCall
|
|
15
|
+
from openai import OpenAI
|
|
16
|
+
from openai.types.chat import (
|
|
17
|
+
ChatCompletionAssistantMessageParam,
|
|
18
|
+
ChatCompletionMessageFunctionToolCallParam,
|
|
19
|
+
ChatCompletionUserMessageParam,
|
|
20
|
+
ChatCompletionSystemMessageParam,
|
|
21
|
+
ChatCompletionToolMessageParam,
|
|
22
|
+
|
|
23
|
+
ChatCompletionMessageParam,
|
|
24
|
+
ChatCompletionChunk,
|
|
25
|
+
ChatCompletion,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
class NodeEventTypes(str, Enum):
|
|
29
|
+
CREATED = 'CREATED'
|
|
30
|
+
UPDATED = 'UPDATED'
|
|
31
|
+
FINISNED = 'FINISHED'
|
|
32
|
+
ERROR = 'ERROR'
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class NodeEvent:
|
|
36
|
+
""" 节点发生变化的事件 """
|
|
37
|
+
node: Node
|
|
38
|
+
_type: NodeEventTypes
|
|
39
|
+
|
|
40
|
+
class OpenAIClient:
|
|
41
|
+
def __init__(self, is_main_model: bool, mcp_loader: MCPLoader):
|
|
42
|
+
with ConfigLoader('./configs/ai.json', AIConfig) as config:
|
|
43
|
+
if is_main_model:
|
|
44
|
+
modelcfg = config.main_model
|
|
45
|
+
else:
|
|
46
|
+
modelcfg = config.sub_model
|
|
47
|
+
self.client = OpenAI(base_url=modelcfg.base_url, api_key=modelcfg.api_key)
|
|
48
|
+
self.model = modelcfg.model_name
|
|
49
|
+
# self.toolset = Toolset()
|
|
50
|
+
self.mcp_loader = mcp_loader
|
|
51
|
+
self.log = getLogger(f"Model_{self.model}")
|
|
52
|
+
|
|
53
|
+
def ilina_to_openai(self, ilina: IlinaMessage) -> ChatCompletionMessageParam|None:
|
|
54
|
+
""" 将 IlinaMessage 转化为 ChatCompletionMessageParam, 会过滤掉 error 类型"""
|
|
55
|
+
if ilina.role == 'assistant':
|
|
56
|
+
tool_calls = []
|
|
57
|
+
for tool_call in ilina.tool_calls:
|
|
58
|
+
tool_calls.append(ChatCompletionMessageFunctionToolCallParam(
|
|
59
|
+
type='function',
|
|
60
|
+
id=tool_call.tool_call_id,
|
|
61
|
+
function={
|
|
62
|
+
'name': tool_call.name,
|
|
63
|
+
'arguments': tool_call.arguments
|
|
64
|
+
}
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
if len(tool_calls) > 0:
|
|
68
|
+
return ChatCompletionAssistantMessageParam(
|
|
69
|
+
role='assistant',
|
|
70
|
+
content=ilina.content,
|
|
71
|
+
tool_calls=tool_calls
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
return ChatCompletionAssistantMessageParam(
|
|
75
|
+
role='assistant',
|
|
76
|
+
content=ilina.content,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
elif ilina.role == 'user':
|
|
80
|
+
return ChatCompletionUserMessageParam(
|
|
81
|
+
role='user',
|
|
82
|
+
content=ilina.content
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
elif ilina.role == 'system':
|
|
86
|
+
return ChatCompletionSystemMessageParam(
|
|
87
|
+
role='system',
|
|
88
|
+
content=ilina.content
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
elif ilina.role == 'tool':
|
|
92
|
+
return ChatCompletionToolMessageParam(
|
|
93
|
+
role='tool',
|
|
94
|
+
content=ilina.content,
|
|
95
|
+
tool_call_id=ilina.tool_call_id
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
elif ilina.role == 'error':
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def chat(self, messages: list[IlinaMessage]) -> Generator[NodeEvent, None, None]:
|
|
102
|
+
""" 调用模型,会首先用生成器返回流失输出结果,最后return合并的Node """
|
|
103
|
+
new_messages: list[IlinaMessage] = [] # 存储本轮调用生成的消息
|
|
104
|
+
stop_reason: str = '' # 存储停止原因
|
|
105
|
+
self.log.info('开始调用模型')
|
|
106
|
+
while stop_reason != 'stop':
|
|
107
|
+
# 发起请求
|
|
108
|
+
self.log.info('发起 API 请求')
|
|
109
|
+
try:
|
|
110
|
+
# 首先将 messages 转换成 openai 格式
|
|
111
|
+
openai_messages = []
|
|
112
|
+
for item in map(self.ilina_to_openai, messages + new_messages):
|
|
113
|
+
if item is not None:
|
|
114
|
+
openai_messages.append(item)
|
|
115
|
+
|
|
116
|
+
# 发起调用
|
|
117
|
+
res = self.client.chat.completions.create(
|
|
118
|
+
messages=openai_messages,
|
|
119
|
+
model=self.model,
|
|
120
|
+
tools=self.mcp_loader.get_list_openai(),
|
|
121
|
+
stream=True
|
|
122
|
+
)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
yield NodeEvent(Node(IlinaMessage(role='error', content=f'{e}')), NodeEventTypes.ERROR)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
assistant_node = Node(IlinaMessage(role='assistant'))
|
|
128
|
+
# 传递节点开始事件
|
|
129
|
+
yield NodeEvent(assistant_node, NodeEventTypes.CREATED)
|
|
130
|
+
|
|
131
|
+
# 逐帧解析
|
|
132
|
+
tool_call_nodes: dict[str, Node] = {} # 保存工具 ID 对应的节点
|
|
133
|
+
for chunk in res:
|
|
134
|
+
chunk: ChatCompletionChunk
|
|
135
|
+
delta = chunk.choices[0].delta
|
|
136
|
+
|
|
137
|
+
# 保存流式信息
|
|
138
|
+
if delta.content:
|
|
139
|
+
assistant_node.message.content += delta.content
|
|
140
|
+
yield NodeEvent(assistant_node, NodeEventTypes.UPDATED)
|
|
141
|
+
|
|
142
|
+
# 保存思考信息
|
|
143
|
+
reasoning_content_delta = ''
|
|
144
|
+
try:
|
|
145
|
+
self.log.debug(delta)
|
|
146
|
+
if hasattr(delta, 'reasoning_content'): # Deepseek:用 reasoning_content 输出思考流
|
|
147
|
+
reasoning_content_delta = delta.reasoning_content or '' # pyright: ignore[reportAttributeAccessIssue]
|
|
148
|
+
elif hasattr(delta, 'reasoning'): # Ollama: 用 reasoning 输出思考流
|
|
149
|
+
reasoning_content_delta = delta.reasoning or '' # pyright: ignore[reportAttributeAccessIssue]
|
|
150
|
+
except AttributeError: # 如果获取不到,也设置成空
|
|
151
|
+
reasoning_content_delta = ''
|
|
152
|
+
|
|
153
|
+
assistant_node.message.reasoning_content += reasoning_content_delta
|
|
154
|
+
yield NodeEvent(assistant_node, NodeEventTypes.UPDATED)
|
|
155
|
+
|
|
156
|
+
# 保存工具调用
|
|
157
|
+
if delta.tool_calls: # 如果工具调用不为 None
|
|
158
|
+
for tool_call in delta.tool_calls: # 对于工具调用列表中的每个工具
|
|
159
|
+
try: # 尝试直接追加到现有索引处
|
|
160
|
+
if tool_call.id: # 覆写 ID
|
|
161
|
+
assistant_node.message.tool_calls[tool_call.index].tool_call_id = tool_call.id
|
|
162
|
+
|
|
163
|
+
if tool_call.function: # 更新 function
|
|
164
|
+
if tool_call.function.name: # 覆写 name
|
|
165
|
+
assistant_node.message.tool_calls[tool_call.index].name = tool_call.function.name
|
|
166
|
+
if tool_call.id:
|
|
167
|
+
tool_call_nodes[tool_call.id].message.tool_name = tool_call.function.name
|
|
168
|
+
if tool_call.function.arguments: # 追加 arguments
|
|
169
|
+
assistant_node.message.tool_calls[tool_call.index].arguments += tool_call.function.arguments
|
|
170
|
+
|
|
171
|
+
yield NodeEvent(assistant_node, NodeEventTypes.UPDATED)
|
|
172
|
+
|
|
173
|
+
except IndexError: # 不存在则新增
|
|
174
|
+
assert tool_call.id is not None
|
|
175
|
+
assert tool_call.function is not None
|
|
176
|
+
assert tool_call.function.name is not None
|
|
177
|
+
assert tool_call.function.arguments is not None
|
|
178
|
+
assistant_node.message.tool_calls.append(IlinaToolCall(
|
|
179
|
+
tool_call_id=tool_call.id,
|
|
180
|
+
name=tool_call.function.name,
|
|
181
|
+
arguments=tool_call.function.arguments
|
|
182
|
+
))
|
|
183
|
+
tool_call_node = Node(IlinaMessage(role='tool', tool_call_id=tool_call.id, tool_name=tool_call.function.name))
|
|
184
|
+
tool_call_nodes[tool_call.id] = tool_call_node
|
|
185
|
+
yield NodeEvent(tool_call_node, NodeEventTypes.CREATED)
|
|
186
|
+
yield NodeEvent(assistant_node, NodeEventTypes.UPDATED)
|
|
187
|
+
|
|
188
|
+
# 更新停止原因
|
|
189
|
+
if chunk.choices[0].finish_reason:
|
|
190
|
+
stop_reason = chunk.choices[0].finish_reason
|
|
191
|
+
self.log.info(f'停止原因:{stop_reason}')
|
|
192
|
+
|
|
193
|
+
# 添加助手信息
|
|
194
|
+
new_messages.append(assistant_node.message)
|
|
195
|
+
yield NodeEvent(assistant_node, NodeEventTypes.FINISNED)
|
|
196
|
+
|
|
197
|
+
# 调用并添加工具信息
|
|
198
|
+
# 返回流式传输的工具块
|
|
199
|
+
for call in assistant_node.message.tool_calls:
|
|
200
|
+
self.log.info(f'调用工具 {call.name}')
|
|
201
|
+
result = self.mcp_loader.call(call)
|
|
202
|
+
self.log.info(f'工具返回 {result}')
|
|
203
|
+
# 根据 ID 获取对应的节点并进行修改和发送
|
|
204
|
+
tool_call_node = tool_call_nodes[call.tool_call_id]
|
|
205
|
+
tool_call_node.message.content = result
|
|
206
|
+
# 向新消息列表中添加工具消息
|
|
207
|
+
new_messages.append(tool_call_node.message)
|
|
208
|
+
yield NodeEvent(tool_call_node, NodeEventTypes.UPDATED)
|
|
209
|
+
yield NodeEvent(tool_call_node, NodeEventTypes.FINISNED)
|
|
210
|
+
|
|
211
|
+
self.log.debug(f'当前的new_messages:\n{'\n'.join([str(m) for m in new_messages])}')
|
|
212
|
+
|
|
213
|
+
def once(self, sysprompt: str, user_input: str) -> str|None:
|
|
214
|
+
""" 进行一次调用 """
|
|
215
|
+
res: ChatCompletion = self.client.chat.completions.create(
|
|
216
|
+
messages=[
|
|
217
|
+
{'role': 'system', 'content': sysprompt},
|
|
218
|
+
{'role': 'user', 'content': user_input}
|
|
219
|
+
],
|
|
220
|
+
model=self.model,
|
|
221
|
+
)
|
|
222
|
+
return res.choices[0].message.content
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# 引擎层
|
|
2
|
+
import logging
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from typing import Generator
|
|
5
|
+
|
|
6
|
+
from FovesLog import LoggedTask
|
|
7
|
+
from .tree import Tree, Node
|
|
8
|
+
from .call_openai import *
|
|
9
|
+
from ._ilina_message import IlinaMessage
|
|
10
|
+
from .exceptions import *
|
|
11
|
+
|
|
12
|
+
class Engine:
|
|
13
|
+
def __init__(self, filename: str) -> None:
|
|
14
|
+
self.log = logging.getLogger('对话引擎')
|
|
15
|
+
self.log.setLevel(logging.INFO)
|
|
16
|
+
|
|
17
|
+
with LoggedTask('初始化', logger=self.log) as task:
|
|
18
|
+
self.tree = Tree(filename)
|
|
19
|
+
task.checkpoint(f'建立文件树')
|
|
20
|
+
self.mcp_loader = MCPLoader()
|
|
21
|
+
task.checkpoint(f'建立MCP工具')
|
|
22
|
+
self.main_model = OpenAIClient(True, self.mcp_loader)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def workpath(self) -> str:
|
|
26
|
+
""" 获取工作目录 """
|
|
27
|
+
return str(self.tree.workpath)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
""" 获取树的名字 """
|
|
32
|
+
return self.tree.name
|
|
33
|
+
|
|
34
|
+
@name.setter
|
|
35
|
+
def name(self, value: str):
|
|
36
|
+
self.tree.name = value
|
|
37
|
+
|
|
38
|
+
def set_name(self, new_name: str):
|
|
39
|
+
""" 设置名字的函数版本,适用于某些无法使用属性赋值的情况
|
|
40
|
+
|
|
41
|
+
说的就是你, lambda 语句
|
|
42
|
+
"""
|
|
43
|
+
self.name = new_name
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def message_list(self) -> tuple[list[UUID], list[IlinaMessage]]:
|
|
47
|
+
""" 获取当前的消息链 """
|
|
48
|
+
return self.tree.root_node._to_message_list()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def readonly_root_node(self) -> Node:
|
|
52
|
+
""" 获取根节点。注意:只应该用来获取树结构 """
|
|
53
|
+
return self.tree.root_node
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def readonly_leaves(self) -> list[Node]:
|
|
57
|
+
""" 获取所有叶子节点。注意:只应该用来获取树结构 """
|
|
58
|
+
return self.tree.root_node._get_leaves()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def readonly_now_node(self) -> UUID:
|
|
62
|
+
""" 获取当前最新节点的 UUID """
|
|
63
|
+
return self.tree.root_node._get_now().uuid
|
|
64
|
+
|
|
65
|
+
def get_message_by_uuid(self, uuid: UUID) -> IlinaMessage:
|
|
66
|
+
""" 通过 UUID 获取消息内容 """
|
|
67
|
+
return self.tree.uuid_to_node_table[uuid].message
|
|
68
|
+
|
|
69
|
+
def get_parent(self, uuid: UUID) -> UUID:
|
|
70
|
+
""" 获取某个节点的父节点。对于根节点和其他找不到父节点的节点会报错 """
|
|
71
|
+
parent = self.tree.root_node._get_parent(uuid)
|
|
72
|
+
if parent:
|
|
73
|
+
return parent
|
|
74
|
+
else:
|
|
75
|
+
raise ParentNotFoundError(uuid)
|
|
76
|
+
|
|
77
|
+
def edit_node(self, target: UUID, new_message: IlinaMessage) -> UUID:
|
|
78
|
+
""" 修改节点内容,不会实际修改,而是作为父节点的新子节点插入,会返回新节点的 UUID """
|
|
79
|
+
new_node = Node(new_message)
|
|
80
|
+
with self.tree as tree:
|
|
81
|
+
tree.insert(new_node, self.get_parent(target))
|
|
82
|
+
return new_node.uuid
|
|
83
|
+
|
|
84
|
+
def invoke(self, start_from: UUID|None=None) -> Generator[NodeEvent, bool, None]:
|
|
85
|
+
""" 将当前的对话发送给主模型,并获取其回复,默认从最新分支开始
|
|
86
|
+
并会自动将 NodeFinished 事件的节点添加到节点树中
|
|
87
|
+
如果指定了 start_from,会从那里截断并插入节点。
|
|
88
|
+
如果 start_from 不在当前分支,会首先切换过去。
|
|
89
|
+
"""
|
|
90
|
+
self.log.info(f'调用 AI')
|
|
91
|
+
|
|
92
|
+
# 如果未指定 start_from,就设置到当前的末尾
|
|
93
|
+
if start_from is None:
|
|
94
|
+
start_from = self.readonly_now_node
|
|
95
|
+
|
|
96
|
+
# 如果不在当前分支,就切换过去
|
|
97
|
+
uuids, messages = self.message_list
|
|
98
|
+
if start_from not in uuids:
|
|
99
|
+
self.move_to_node(start_from)
|
|
100
|
+
uuids, messages = self.message_list
|
|
101
|
+
|
|
102
|
+
# 如果节点类型是 assistant,就获从其父节点出截断,否则从节点处截断
|
|
103
|
+
if self.get_message_by_uuid(start_from).role == 'assistant':
|
|
104
|
+
messages = messages[:uuids.index(start_from)]
|
|
105
|
+
append_point_uuid = self.get_parent(start_from)
|
|
106
|
+
else:
|
|
107
|
+
messages = messages[:uuids.index(start_from) + 1]
|
|
108
|
+
append_point_uuid = start_from
|
|
109
|
+
|
|
110
|
+
# 调用并进行处理
|
|
111
|
+
for event in self.main_model.chat(messages):
|
|
112
|
+
stop: bool|None = yield event
|
|
113
|
+
|
|
114
|
+
if event._type == NodeEventTypes.ERROR:
|
|
115
|
+
stop = True
|
|
116
|
+
elif event._type == NodeEventTypes.FINISNED:
|
|
117
|
+
with self.tree as tree:
|
|
118
|
+
tree.insert(event.node, append_point_uuid)
|
|
119
|
+
append_point_uuid = event.node.uuid
|
|
120
|
+
|
|
121
|
+
if stop:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
def send(self, message: IlinaMessage) -> UUID:
|
|
125
|
+
""" 将消息插入到当前节点之后,会返回新节点的 UUID """
|
|
126
|
+
self.log.info(f'插入用户消息...')
|
|
127
|
+
new_node = Node(message)
|
|
128
|
+
with self.tree as tree:
|
|
129
|
+
if not tree.insert(new_node, tree.now_node.uuid):
|
|
130
|
+
raise NodeNotFoundError(tree.now_node.uuid)
|
|
131
|
+
return new_node.uuid
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def move_to_node(self, uuid: UUID):
|
|
135
|
+
""" 将指针设置到指定 UUID 所在分支的末尾 """
|
|
136
|
+
self.log.info(f'移动指针到 {uuid}')
|
|
137
|
+
with self.tree as tree:
|
|
138
|
+
if not tree.set_pointer(uuid):
|
|
139
|
+
raise NodeNotFoundError(uuid)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def delete_node(self, uuid: UUID):
|
|
143
|
+
""" 删除指定 UUID 的节点 """
|
|
144
|
+
self.log.info(f'删除节点: {uuid}')
|
|
145
|
+
with self.tree as root_node:
|
|
146
|
+
if not root_node.delete(uuid):
|
|
147
|
+
raise NodeNotFoundError(uuid)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# 自定义异常
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
class NodeNotFoundError(Exception):
|
|
6
|
+
""" 未找到指定 UUID 所对应的节点 """
|
|
7
|
+
def __init__(self, uuid: UUID, *args: object) -> None:
|
|
8
|
+
message = f'未找到 UUID 为 {uuid} 的节点'
|
|
9
|
+
super().__init__(message, *args)
|
|
10
|
+
self.message = message
|
|
11
|
+
|
|
12
|
+
def __str__(self):
|
|
13
|
+
return self.message
|
|
14
|
+
|
|
15
|
+
class ParentNotFoundError(Exception):
|
|
16
|
+
""" 未找到指定 UUID 所对应的父节点 """
|
|
17
|
+
def __init__(self, uuid: UUID, *args: object) -> None:
|
|
18
|
+
message = f'未找到 UUID 为 {uuid} 的节点的父节点'
|
|
19
|
+
super().__init__(message, *args)
|
|
20
|
+
self.message = message
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
return self.message
|