aipyapp-qpython 0.1.24.20__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.
- aipyapp/__init__.py +1 -0
- aipyapp/__main__.py +36 -0
- aipyapp/aipy/__init__.py +7 -0
- aipyapp/aipy/config.py +105 -0
- aipyapp/aipy/i18n.py +212 -0
- aipyapp/aipy/interface.py +26 -0
- aipyapp/aipy/llm.py +540 -0
- aipyapp/aipy/plugin.py +87 -0
- aipyapp/aipy/prompt.py +684 -0
- aipyapp/aipy/prompt.zh.py +512 -0
- aipyapp/aipy/prompt_en.py +688 -0
- aipyapp/aipy/qpython.py +35 -0
- aipyapp/aipy/runner.py +182 -0
- aipyapp/aipy/task.py +264 -0
- aipyapp/aipy/taskmgr.py +108 -0
- aipyapp/aipy/template.html +290 -0
- aipyapp/aipy/templates.py +54 -0
- aipyapp/aipy/utils.py +76 -0
- aipyapp/default.toml +97 -0
- aipyapp/default_en.toml +97 -0
- aipyapp/gui.py +409 -0
- aipyapp/main.py +80 -0
- aipyapp/publish.py +60 -0
- aipyapp/saas.py +167 -0
- aipyapp/wxgui.py +367 -0
- aipyapp_qpython-0.1.24.20.dist-info/METADATA +44 -0
- aipyapp_qpython-0.1.24.20.dist-info/RECORD +29 -0
- aipyapp_qpython-0.1.24.20.dist-info/WHEEL +5 -0
- aipyapp_qpython-0.1.24.20.dist-info/top_level.txt +1 -0
aipyapp/aipy/llm.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
import openai
|
|
11
|
+
import requests
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
|
|
17
|
+
from .i18n import T
|
|
18
|
+
from .plugin import event_bus
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ChatMessage:
|
|
22
|
+
role: str
|
|
23
|
+
content: str
|
|
24
|
+
reason: str = None
|
|
25
|
+
usage: Counter = field(default_factory=Counter)
|
|
26
|
+
|
|
27
|
+
class LineReceiver(list):
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.buffer = ""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def content(self):
|
|
34
|
+
return '\n'.join(self)
|
|
35
|
+
|
|
36
|
+
def feed(self, data: str):
|
|
37
|
+
self.buffer += data
|
|
38
|
+
new_lines = []
|
|
39
|
+
|
|
40
|
+
while '\n' in self.buffer:
|
|
41
|
+
line, self.buffer = self.buffer.split('\n', 1)
|
|
42
|
+
self.append(line)
|
|
43
|
+
new_lines.append(line)
|
|
44
|
+
|
|
45
|
+
return new_lines
|
|
46
|
+
|
|
47
|
+
class LiveManager:
|
|
48
|
+
def __init__(self, console, name):
|
|
49
|
+
self.live = None
|
|
50
|
+
self.name = name
|
|
51
|
+
self.console = console
|
|
52
|
+
self.lr = LineReceiver()
|
|
53
|
+
self.title = f"{self.name} {T('llm_response')}"
|
|
54
|
+
self.response_panel = None
|
|
55
|
+
self.full_response = None
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
console = self.console if self.console.quiet else None
|
|
59
|
+
self.live = Live(console=console, auto_refresh=False, vertical_overflow='visible', transient=True)
|
|
60
|
+
self.live.__enter__()
|
|
61
|
+
status = self.console.status(f"[dim white]{self.name} {T('thinking')}...", spinner='runner')
|
|
62
|
+
response_panel = Panel(status, title=f"[blue] 🤖️ {self.title} ")
|
|
63
|
+
self.live.update(response_panel, refresh=True)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def process_chunk(self, content):
|
|
67
|
+
if not content: return
|
|
68
|
+
lines = self.lr.feed(content)
|
|
69
|
+
if not lines: return
|
|
70
|
+
|
|
71
|
+
content = '\n'.join(lines)
|
|
72
|
+
event_bus.broadcast('response_stream', {'llm': self.name, 'content': content})
|
|
73
|
+
if hasattr(self.console, 'gui'):
|
|
74
|
+
self.console.print(content, end="", highlight=False)
|
|
75
|
+
|
|
76
|
+
full_response = self.lr.content
|
|
77
|
+
try:
|
|
78
|
+
md = Markdown(full_response)
|
|
79
|
+
response_panel = Panel(md, title=f"[green] 🤖️ {self.title} ")
|
|
80
|
+
except Exception:
|
|
81
|
+
text = Text(full_response)
|
|
82
|
+
response_panel = Panel(text, title=f"[yellow] 🤖️ {self.title} ")
|
|
83
|
+
self.live.update(response_panel, refresh=True)
|
|
84
|
+
self.response_panel = response_panel
|
|
85
|
+
self.full_response = full_response
|
|
86
|
+
|
|
87
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
88
|
+
if self.lr.buffer:
|
|
89
|
+
self.process_chunk('\n')
|
|
90
|
+
self.live.__exit__(exc_type, exc_val, exc_tb)
|
|
91
|
+
|
|
92
|
+
class ChatHistory:
|
|
93
|
+
def __init__(self):
|
|
94
|
+
self.messages = []
|
|
95
|
+
self._total_tokens = Counter()
|
|
96
|
+
|
|
97
|
+
def __len__(self):
|
|
98
|
+
return len(self.messages)
|
|
99
|
+
|
|
100
|
+
def json(self):
|
|
101
|
+
return [msg.__dict__ for msg in self.messages]
|
|
102
|
+
|
|
103
|
+
def add(self, role, content):
|
|
104
|
+
self.add_message(ChatMessage(role=role, content=content))
|
|
105
|
+
|
|
106
|
+
def add_message(self, message: ChatMessage):
|
|
107
|
+
self.messages.append(message)
|
|
108
|
+
self._total_tokens += message.usage
|
|
109
|
+
|
|
110
|
+
def get_usage(self):
|
|
111
|
+
return iter(row.usage for row in self.messages if row.role == "assistant")
|
|
112
|
+
|
|
113
|
+
def get_summary(self):
|
|
114
|
+
summary = {'time': 0, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0}
|
|
115
|
+
summary.update(dict(self._total_tokens))
|
|
116
|
+
summary['rounds'] = sum(1 for row in self.messages if row.role == "assistant")
|
|
117
|
+
return summary
|
|
118
|
+
|
|
119
|
+
def get_messages(self):
|
|
120
|
+
return [{"role": msg.role, "content": msg.content} for msg in self.messages]
|
|
121
|
+
|
|
122
|
+
class BaseClient(ABC):
|
|
123
|
+
MODEL = None
|
|
124
|
+
BASE_URL = None
|
|
125
|
+
RPS = 2
|
|
126
|
+
MAX_TOKENS = 8192
|
|
127
|
+
PARAMS = {}
|
|
128
|
+
|
|
129
|
+
def __init__(self, config):
|
|
130
|
+
self.name = None
|
|
131
|
+
self.console = None
|
|
132
|
+
self.max_tokens = config.get("max_tokens") or self.MAX_TOKENS
|
|
133
|
+
self._model = config.get("model") or self.MODEL
|
|
134
|
+
self._timeout = config.get("timeout")
|
|
135
|
+
self._api_key = config.get("api_key")
|
|
136
|
+
self._base_url = config.get("base_url") or self.BASE_URL
|
|
137
|
+
self._stream = config.get("stream", True)
|
|
138
|
+
self._client = None
|
|
139
|
+
self._params = {}
|
|
140
|
+
if self.PARAMS:
|
|
141
|
+
self._params.update(self.PARAMS)
|
|
142
|
+
temperature = config.get("temperature")
|
|
143
|
+
if temperature != None and temperature >= 0 and temperature <= 1:
|
|
144
|
+
self._params['temperature'] = temperature
|
|
145
|
+
|
|
146
|
+
def __repr__(self):
|
|
147
|
+
return f"{self.__class__.__name__}<{self.name}>({self._model}, {self.max_tokens})"
|
|
148
|
+
|
|
149
|
+
def is_stopped(self):
|
|
150
|
+
return event_bus.is_stopped()
|
|
151
|
+
|
|
152
|
+
def usable(self):
|
|
153
|
+
return self._model
|
|
154
|
+
|
|
155
|
+
def _get_client(self):
|
|
156
|
+
return self._client
|
|
157
|
+
|
|
158
|
+
@abstractmethod
|
|
159
|
+
def get_completion(self, messages):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def add_system_prompt(self, history, system_prompt):
|
|
163
|
+
history.add("system", system_prompt)
|
|
164
|
+
|
|
165
|
+
@abstractmethod
|
|
166
|
+
def _parse_usage(self, response):
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
@abstractmethod
|
|
170
|
+
def _parse_stream_response(self, response):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
@abstractmethod
|
|
174
|
+
def _parse_response(self, response):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def parse_response(self, response):
|
|
178
|
+
if self._stream:
|
|
179
|
+
response = self._parse_stream_response(response)
|
|
180
|
+
else:
|
|
181
|
+
response = self._parse_response(response)
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
def __call__(self, history, prompt, system_prompt=None):
|
|
185
|
+
# We shall only send system prompt once
|
|
186
|
+
if not history and system_prompt:
|
|
187
|
+
self.add_system_prompt(history, system_prompt)
|
|
188
|
+
history.add("user", prompt)
|
|
189
|
+
|
|
190
|
+
start = time.time()
|
|
191
|
+
self.console.record = False
|
|
192
|
+
with self.console.status(f"[dim white]{T('sending_task', self.name)} ..."):
|
|
193
|
+
response = self.get_completion(history.get_messages())
|
|
194
|
+
self.console.record = True
|
|
195
|
+
end = time.time()
|
|
196
|
+
if response:
|
|
197
|
+
msg = self.parse_response(response)
|
|
198
|
+
msg.usage['time'] = round(end - start, 3)
|
|
199
|
+
history.add_message(msg)
|
|
200
|
+
response = msg.content
|
|
201
|
+
return response
|
|
202
|
+
|
|
203
|
+
# https://platform.openai.com/docs/api-reference/chat/create
|
|
204
|
+
# https://api-docs.deepseek.com/api/create-chat-completion
|
|
205
|
+
class OpenAIBaseClient(BaseClient):
|
|
206
|
+
def usable(self):
|
|
207
|
+
return super().usable() and self._api_key
|
|
208
|
+
|
|
209
|
+
def _get_client(self):
|
|
210
|
+
return openai.Client(api_key=self._api_key, base_url=self._base_url, timeout=self._timeout)
|
|
211
|
+
|
|
212
|
+
def add_system_prompt(self, history, system_prompt):
|
|
213
|
+
history.add("system", system_prompt)
|
|
214
|
+
|
|
215
|
+
def _parse_usage(self, usage):
|
|
216
|
+
usage = Counter({'total_tokens': usage.total_tokens,
|
|
217
|
+
'input_tokens': usage.prompt_tokens,
|
|
218
|
+
'output_tokens': usage.completion_tokens})
|
|
219
|
+
return usage
|
|
220
|
+
|
|
221
|
+
def _parse_stream_response(self, response):
|
|
222
|
+
usage = Counter()
|
|
223
|
+
with LiveManager(self.console, self.name) as lm:
|
|
224
|
+
for chunk in response:
|
|
225
|
+
if hasattr(chunk, 'usage') and chunk.usage is not None:
|
|
226
|
+
usage = self._parse_usage(chunk.usage)
|
|
227
|
+
|
|
228
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
229
|
+
content = chunk.choices[0].delta.content
|
|
230
|
+
lm.process_chunk(content)
|
|
231
|
+
|
|
232
|
+
if self.is_stopped():
|
|
233
|
+
break
|
|
234
|
+
response_panel = lm.response_panel
|
|
235
|
+
full_response = lm.full_response
|
|
236
|
+
if response_panel: self.console.print(response_panel)
|
|
237
|
+
#segments = self.console.render(response_panel)
|
|
238
|
+
#self.console._record_buffer.extend(segments)
|
|
239
|
+
return ChatMessage(role="assistant", content=full_response, usage=usage)
|
|
240
|
+
|
|
241
|
+
def _parse_response(self, response):
|
|
242
|
+
message = response.choices[0].message
|
|
243
|
+
reason = getattr(message, "reasoning_content", None)
|
|
244
|
+
return ChatMessage(
|
|
245
|
+
role=message.role,
|
|
246
|
+
content=message.content,
|
|
247
|
+
reason=reason,
|
|
248
|
+
usage=self._parse_usage(response.usage)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def get_completion(self, messages):
|
|
252
|
+
if not self._client:
|
|
253
|
+
self._client = self._get_client()
|
|
254
|
+
try:
|
|
255
|
+
response = self._client.chat.completions.create(
|
|
256
|
+
model = self._model,
|
|
257
|
+
messages = messages,
|
|
258
|
+
stream=self._stream,
|
|
259
|
+
max_tokens = self.max_tokens,
|
|
260
|
+
**self._params
|
|
261
|
+
)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
self.console.print(f"❌ [bold red]{self.name} API {T('call_failed')}: [yellow]{str(e)}")
|
|
264
|
+
response = None
|
|
265
|
+
return response
|
|
266
|
+
|
|
267
|
+
# https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
268
|
+
class OllamaClient(BaseClient):
|
|
269
|
+
def __init__(self, config):
|
|
270
|
+
super().__init__(config)
|
|
271
|
+
self._session = requests.Session()
|
|
272
|
+
|
|
273
|
+
def usable(self):
|
|
274
|
+
return super().usable() and self._base_url
|
|
275
|
+
|
|
276
|
+
def _parse_usage(self, response):
|
|
277
|
+
ret = {'input_tokens': response['prompt_eval_count'], 'output_tokens': response['eval_count']}
|
|
278
|
+
ret['total_tokens'] = ret['input_tokens'] + ret['output_tokens']
|
|
279
|
+
return ret
|
|
280
|
+
|
|
281
|
+
def _parse_stream_response(self, response):
|
|
282
|
+
with LiveManager(self.console, self.name) as lm:
|
|
283
|
+
for chunk in response.iter_lines():
|
|
284
|
+
chunk = chunk.decode(encoding='utf-8')
|
|
285
|
+
msg = json.loads(chunk)
|
|
286
|
+
if msg['done']:
|
|
287
|
+
usage = self._parse_usage(msg)
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if 'message' in msg and 'content' in msg['message'] and msg['message']['content']:
|
|
291
|
+
content = msg['message']['content']
|
|
292
|
+
lm.process_chunk(content)
|
|
293
|
+
|
|
294
|
+
if self.is_stopped():
|
|
295
|
+
break
|
|
296
|
+
response_panel = lm.response_panel
|
|
297
|
+
full_response = lm.full_response
|
|
298
|
+
if response_panel: self.console.print(response_panel)
|
|
299
|
+
return ChatMessage(role="assistant", content=full_response, usage=usage)
|
|
300
|
+
|
|
301
|
+
def _parse_response(self, response):
|
|
302
|
+
response = response.json()
|
|
303
|
+
msg = response["message"]
|
|
304
|
+
return ChatMessage(role=msg['role'], content=msg['content'], usage=self._parse_usage(response))
|
|
305
|
+
|
|
306
|
+
def get_completion(self, messages):
|
|
307
|
+
try:
|
|
308
|
+
response = self._session.post(
|
|
309
|
+
f"{self._base_url}/api/chat",
|
|
310
|
+
json={
|
|
311
|
+
"model": self._model,
|
|
312
|
+
"messages": messages,
|
|
313
|
+
"stream": self._stream,
|
|
314
|
+
"options": {"num_predict": self.max_tokens}
|
|
315
|
+
},
|
|
316
|
+
timeout=self._timeout,
|
|
317
|
+
**self._params
|
|
318
|
+
)
|
|
319
|
+
response.raise_for_status()
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self.console.print(f"❌ [bold red]{self.name} API {T('call_failed')}: [yellow]{str(e)}")
|
|
322
|
+
response = None
|
|
323
|
+
return response
|
|
324
|
+
|
|
325
|
+
# https://docs.anthropic.com/en/api/messages
|
|
326
|
+
class ClaudeClient(BaseClient):
|
|
327
|
+
MODEL = "claude-3-7-sonnet-20250219"
|
|
328
|
+
|
|
329
|
+
def __init__(self, config):
|
|
330
|
+
super().__init__(config)
|
|
331
|
+
self._system_prompt = None
|
|
332
|
+
|
|
333
|
+
def _get_client(self):
|
|
334
|
+
import anthropic
|
|
335
|
+
return anthropic.Anthropic(api_key=self._api_key, timeout=self._timeout)
|
|
336
|
+
|
|
337
|
+
def usable(self):
|
|
338
|
+
return super().usable() and self._api_key
|
|
339
|
+
|
|
340
|
+
def _parse_usage(self, response):
|
|
341
|
+
usage = response.usage
|
|
342
|
+
ret = {'input_tokens': usage.input_tokens, 'output_tokens': usage.output_tokens}
|
|
343
|
+
ret['total_tokens'] = ret['input_tokens'] + ret['output_tokens']
|
|
344
|
+
return ret
|
|
345
|
+
|
|
346
|
+
def _parse_stream_response(self, response):
|
|
347
|
+
usage = Counter()
|
|
348
|
+
with LiveManager(self.console, self.name) as lm:
|
|
349
|
+
for event in response:
|
|
350
|
+
if hasattr(event, 'delta') and hasattr(event.delta, 'text') and event.delta.text:
|
|
351
|
+
content = event.delta.text
|
|
352
|
+
lm.process_chunk(content)
|
|
353
|
+
elif hasattr(event, 'message') and hasattr(event.message, 'usage') and event.message.usage:
|
|
354
|
+
usage['input_tokens'] += getattr(event.message.usage, 'input_tokens', 0)
|
|
355
|
+
usage['output_tokens'] += getattr(event.message.usage, 'output_tokens', 0)
|
|
356
|
+
elif hasattr(event, 'usage') and event.usage:
|
|
357
|
+
usage['input_tokens'] += getattr(event.usage, 'input_tokens', 0)
|
|
358
|
+
usage['output_tokens'] += getattr(event.usage, 'output_tokens', 0)
|
|
359
|
+
|
|
360
|
+
if self.is_stopped():
|
|
361
|
+
break
|
|
362
|
+
|
|
363
|
+
response_panel = lm.response_panel
|
|
364
|
+
full_response = lm.full_response
|
|
365
|
+
usage['total_tokens'] = usage['input_tokens'] + usage['output_tokens']
|
|
366
|
+
if response_panel: self.console.print(response_panel)
|
|
367
|
+
return ChatMessage(role="assistant", content=full_response, usage=usage)
|
|
368
|
+
|
|
369
|
+
def _parse_response(self, response):
|
|
370
|
+
content = response.content[0].text
|
|
371
|
+
role = response.role
|
|
372
|
+
return ChatMessage(role=role, content=content, usage=self._parse_usage(response))
|
|
373
|
+
|
|
374
|
+
def add_system_prompt(self, history, system_prompt):
|
|
375
|
+
self._system_prompt = system_prompt
|
|
376
|
+
|
|
377
|
+
def get_completion(self, messages):
|
|
378
|
+
if not self._client:
|
|
379
|
+
self._client = self._get_client()
|
|
380
|
+
try:
|
|
381
|
+
message = self._client.messages.create(
|
|
382
|
+
model = self._model,
|
|
383
|
+
messages = messages,
|
|
384
|
+
stream=self._stream,
|
|
385
|
+
system=self._system_prompt,
|
|
386
|
+
max_tokens = self.max_tokens,
|
|
387
|
+
**self._params
|
|
388
|
+
)
|
|
389
|
+
except Exception as e:
|
|
390
|
+
self.console.print(f"❌ [bold red]{self.name} API {T('call_failed')}: [yellow]{str(e)}")
|
|
391
|
+
message = None
|
|
392
|
+
return message
|
|
393
|
+
|
|
394
|
+
class OpenAIClient(OpenAIBaseClient):
|
|
395
|
+
MODEL = 'gpt-4o'
|
|
396
|
+
PARAMS = {'stream_options': {'include_usage': True}}
|
|
397
|
+
|
|
398
|
+
class GeminiClient(OpenAIBaseClient):
|
|
399
|
+
BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/'
|
|
400
|
+
MODEL = 'gemini-2.5-pro-exp-03-25'
|
|
401
|
+
PARAMS = {'stream_options': {'include_usage': True}}
|
|
402
|
+
|
|
403
|
+
class DeepSeekClient(OpenAIBaseClient):
|
|
404
|
+
BASE_URL = 'https://api.deepseek.com'
|
|
405
|
+
MODEL = 'deepseek-chat'
|
|
406
|
+
|
|
407
|
+
class GrokClient(OpenAIBaseClient):
|
|
408
|
+
BASE_URL = 'https://api.x.ai/v1/'
|
|
409
|
+
MODEL = 'grok-3-mini'
|
|
410
|
+
PARAMS = {'stream_options': {'include_usage': True}}
|
|
411
|
+
|
|
412
|
+
class TrustClient(OpenAIBaseClient):
|
|
413
|
+
BASE_URL = 'https://api.trustoken.ai/v1'
|
|
414
|
+
MODEL = 'auto'
|
|
415
|
+
PARAMS = {'stream_options': {'include_usage': True}}
|
|
416
|
+
|
|
417
|
+
class AzureOpenAIClient(OpenAIBaseClient):
|
|
418
|
+
MODEL = 'gpt-4o'
|
|
419
|
+
|
|
420
|
+
def __init__(self, config):
|
|
421
|
+
super().__init__(config)
|
|
422
|
+
self._end_point = config.get('endpoint')
|
|
423
|
+
|
|
424
|
+
def usable(self):
|
|
425
|
+
return super().usable() and self._end_point
|
|
426
|
+
|
|
427
|
+
def _get_client(self):
|
|
428
|
+
from openai import AzureOpenAI
|
|
429
|
+
return AzureOpenAI(azure_endpoint=self._end_point, api_key=self._api_key, api_version="2024-02-01")
|
|
430
|
+
|
|
431
|
+
class PGPTAIClient(OpenAIBaseClient):
|
|
432
|
+
BASE_URL = "https://openai.pgpt.cloud/v1/"
|
|
433
|
+
MODEL = "auto"
|
|
434
|
+
|
|
435
|
+
class LLM(object):
|
|
436
|
+
CLIENTS = {
|
|
437
|
+
"openai": OpenAIClient,
|
|
438
|
+
"ollama": OllamaClient,
|
|
439
|
+
"claude": ClaudeClient,
|
|
440
|
+
"gemini": GeminiClient,
|
|
441
|
+
"pgptai": PGPTAIClient,
|
|
442
|
+
"deepseek": DeepSeekClient,
|
|
443
|
+
'grok': GrokClient,
|
|
444
|
+
'trust': TrustClient,
|
|
445
|
+
'azure': AzureOpenAIClient
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
def __init__(self, settings, console,system_prompt=None):
|
|
449
|
+
self.llms = {}
|
|
450
|
+
self.console = console
|
|
451
|
+
self.default = None
|
|
452
|
+
self._last = None
|
|
453
|
+
self.history = ChatHistory()
|
|
454
|
+
self.system_prompt = system_prompt
|
|
455
|
+
names = defaultdict(set)
|
|
456
|
+
for name, config in settings.llm.items():
|
|
457
|
+
if not config.get('enable', True):
|
|
458
|
+
names['disabled'].add(name)
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
client = self.get_client(config)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
self.console.print_exception()
|
|
465
|
+
names['error'].add(name)
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
names['enabled'].add(name)
|
|
469
|
+
client.name = name
|
|
470
|
+
client.console = console
|
|
471
|
+
self.llms[name] = client
|
|
472
|
+
|
|
473
|
+
if config.get('default', False) and not self.default:
|
|
474
|
+
self.default = client
|
|
475
|
+
names['default'] = name
|
|
476
|
+
|
|
477
|
+
if not self.default:
|
|
478
|
+
name = list(self.llms.keys())[0]
|
|
479
|
+
self.default = self.llms[name]
|
|
480
|
+
names['default'] = name
|
|
481
|
+
self.current = self.default
|
|
482
|
+
self.names = names
|
|
483
|
+
|
|
484
|
+
def __len__(self):
|
|
485
|
+
return len(self.llms)
|
|
486
|
+
|
|
487
|
+
def __repr__(self):
|
|
488
|
+
return f"Current: {'default' if self.current == self.default else self.current}, Default: {self.default}"
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def enabled(self):
|
|
492
|
+
return self.names['enabled']
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def last(self):
|
|
496
|
+
return self._last.name if self._last else None
|
|
497
|
+
|
|
498
|
+
def get_last_message(self, role='assistant'):
|
|
499
|
+
return self.history.get_last_message(role)
|
|
500
|
+
|
|
501
|
+
def clear(self):
|
|
502
|
+
self.history = ChatHistory()
|
|
503
|
+
|
|
504
|
+
def get_client(self, config):
|
|
505
|
+
proto = config.get("type", "openai")
|
|
506
|
+
|
|
507
|
+
client = self.CLIENTS.get(proto.lower())
|
|
508
|
+
if not client:
|
|
509
|
+
raise ValueError(f"Unsupported LLM provider: {proto}")
|
|
510
|
+
return client(config)
|
|
511
|
+
|
|
512
|
+
def __contains__(self, name):
|
|
513
|
+
return name in self.llms
|
|
514
|
+
|
|
515
|
+
def use(self, name):
|
|
516
|
+
llm = self.llms.get(name)
|
|
517
|
+
if llm and llm.usable():
|
|
518
|
+
self.current = llm
|
|
519
|
+
return True
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
def __call__(self, instruction, *, system_prompt=None, name=None):
|
|
523
|
+
""" LLM 选择规则
|
|
524
|
+
1. 如果 name 为 None, 使用 current
|
|
525
|
+
2. 如果 name 存在,使用 name 对应的
|
|
526
|
+
3. 使用 default
|
|
527
|
+
"""
|
|
528
|
+
if not name:
|
|
529
|
+
llm = self.current
|
|
530
|
+
else:
|
|
531
|
+
llm = self.llms.get(name, self.default)
|
|
532
|
+
|
|
533
|
+
if not llm.usable():
|
|
534
|
+
self.console.print(f"[red]LLM: {name} {T('not usable')}")
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
self._last = llm
|
|
538
|
+
ret = llm(self.history, instruction, system_prompt=system_prompt)
|
|
539
|
+
event_bus.broadcast('response_complete', {'llm': llm.name, 'content': ret})
|
|
540
|
+
return ret
|
aipyapp/aipy/plugin.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import traceback
|
|
7
|
+
import importlib.util
|
|
8
|
+
from typing import Callable, Any, Dict, List
|
|
9
|
+
|
|
10
|
+
class EventBus:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._listeners: Dict[str, List[Callable[..., Any]]] = {}
|
|
13
|
+
self._stop_event = threading.Event()
|
|
14
|
+
|
|
15
|
+
def stop(self):
|
|
16
|
+
self._stop_event.set()
|
|
17
|
+
|
|
18
|
+
def is_stopped(self):
|
|
19
|
+
return self._stop_event.is_set()
|
|
20
|
+
|
|
21
|
+
def __repr__(self):
|
|
22
|
+
return repr(self._listeners)
|
|
23
|
+
|
|
24
|
+
def register(self, event_name: str, handler: Callable[..., Any]):
|
|
25
|
+
self._listeners.setdefault(event_name, []).append(handler)
|
|
26
|
+
|
|
27
|
+
def broadcast(self, event_name: str, *args, **kwargs):
|
|
28
|
+
for handler in self._listeners.get(event_name, []):
|
|
29
|
+
try:
|
|
30
|
+
handler(*args, **kwargs)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
traceback.print_exc()
|
|
33
|
+
|
|
34
|
+
def pipeline(self, event_name: str, data, **kwargs):
|
|
35
|
+
for handler in self._listeners.get(event_name, []):
|
|
36
|
+
try:
|
|
37
|
+
data = handler(data, **kwargs)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
traceback.print_exc()
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
def collect(self, event_name: str, *args, **kwargs):
|
|
43
|
+
try:
|
|
44
|
+
ret = [handler(*args, **kwargs) for handler in self._listeners.get(event_name, [])]
|
|
45
|
+
except Exception as e:
|
|
46
|
+
ret = []
|
|
47
|
+
traceback.print_exc()
|
|
48
|
+
return ret
|
|
49
|
+
|
|
50
|
+
def __call__(self, event_name: str, *args, **kwargs):
|
|
51
|
+
return self.pipeline(event_name, *args, **kwargs)
|
|
52
|
+
|
|
53
|
+
event_bus = EventBus()
|
|
54
|
+
|
|
55
|
+
class PluginManager:
|
|
56
|
+
def __init__(self, plugin_dir: str):
|
|
57
|
+
self.plugin_dir = plugin_dir
|
|
58
|
+
self.plugins: Dict[str, Any] = {}
|
|
59
|
+
|
|
60
|
+
def load_plugins(self):
|
|
61
|
+
if not os.path.exists(self.plugin_dir):
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
for fname in os.listdir(self.plugin_dir):
|
|
65
|
+
if fname.endswith(".py") and not fname.startswith("_"):
|
|
66
|
+
self._load_plugin(os.path.join(self.plugin_dir, fname))
|
|
67
|
+
|
|
68
|
+
def _load_plugin(self, filepath: str):
|
|
69
|
+
plugin_id = os.path.basename(filepath)[:-3]
|
|
70
|
+
|
|
71
|
+
spec = importlib.util.spec_from_file_location(plugin_id, filepath)
|
|
72
|
+
module = importlib.util.module_from_spec(spec)
|
|
73
|
+
spec.loader.exec_module(module)
|
|
74
|
+
|
|
75
|
+
plugin_cls = getattr(module, "Plugin", None)
|
|
76
|
+
if not plugin_cls or not callable(plugin_cls):
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
plugin = plugin_cls()
|
|
80
|
+
|
|
81
|
+
for attr_name in dir(plugin):
|
|
82
|
+
if attr_name.startswith("on_") and len(attr_name) > 3:
|
|
83
|
+
handler = getattr(plugin, attr_name)
|
|
84
|
+
if callable(handler):
|
|
85
|
+
event_bus.register(attr_name[3:], handler)
|
|
86
|
+
|
|
87
|
+
self.plugins[plugin_id] = plugin
|