w1x-cli 3.5.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.
- w1x/__init__.py +5 -0
- w1x/app.py +754 -0
- w1x/config.py +189 -0
- w1x/engine.py +565 -0
- w1x/memory.py +272 -0
- w1x/models.py +458 -0
- w1x/puller.py +955 -0
- w1x/terminal.py +661 -0
- w1x/testpy.py +325 -0
- w1x/tools.py +897 -0
- w1x_cli-3.5.0.dist-info/METADATA +178 -0
- w1x_cli-3.5.0.dist-info/RECORD +16 -0
- w1x_cli-3.5.0.dist-info/WHEEL +5 -0
- w1x_cli-3.5.0.dist-info/entry_points.txt +2 -0
- w1x_cli-3.5.0.dist-info/licenses/LICENSE +21 -0
- w1x_cli-3.5.0.dist-info/top_level.txt +1 -0
w1x/tools.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Система встроенных инструментов W1x (поиск, время, арифметика, файлы, Antigravity, Python)."""
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import operator
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from ddgs import DDGS
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.syntax import Syntax
|
|
18
|
+
from rich.tree import Tree
|
|
19
|
+
|
|
20
|
+
from .config import (
|
|
21
|
+
ACTIVE_FILE_ROOT,
|
|
22
|
+
ACTIVE_AGY_PROCESS,
|
|
23
|
+
ACTIVE_AGY_LOCK,
|
|
24
|
+
AGY_TIMEOUT_SECONDS,
|
|
25
|
+
AGY_MAX_PROMPT_CHARS,
|
|
26
|
+
AGY_MAX_OUTPUT_CHARS,
|
|
27
|
+
)
|
|
28
|
+
from .terminal import (
|
|
29
|
+
console,
|
|
30
|
+
session,
|
|
31
|
+
render_message,
|
|
32
|
+
run_with_spinner,
|
|
33
|
+
HTML,
|
|
34
|
+
)
|
|
35
|
+
from .memory import memory
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def search_web(query: str) -> str:
|
|
39
|
+
"""Найти свежую информацию в интернете.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
query: Короткий и точный поисковый запрос.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Заголовки, фрагменты текста и адреса найденных страниц.
|
|
46
|
+
"""
|
|
47
|
+
query = query.strip()
|
|
48
|
+
if not query:
|
|
49
|
+
return "Ошибка: пустой поисковый запрос."
|
|
50
|
+
|
|
51
|
+
has_cyrillic = any("а" <= char.lower() <= "я" or char.lower() == "ё" for char in query)
|
|
52
|
+
regions = ["ru-ru", "us-en"] if has_cyrillic else ["us-en", "ru-ru"]
|
|
53
|
+
results = []
|
|
54
|
+
seen_urls = set()
|
|
55
|
+
search = DDGS(timeout=12)
|
|
56
|
+
for region in regions:
|
|
57
|
+
try:
|
|
58
|
+
region_results = search.text(
|
|
59
|
+
query,
|
|
60
|
+
region=region,
|
|
61
|
+
safesearch="off",
|
|
62
|
+
max_results=5,
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
continue
|
|
66
|
+
for item in region_results:
|
|
67
|
+
url = str(item.get("href") or item.get("url") or "").strip()
|
|
68
|
+
if url and url not in seen_urls:
|
|
69
|
+
seen_urls.add(url)
|
|
70
|
+
results.append(item)
|
|
71
|
+
if len(results) >= 3:
|
|
72
|
+
break
|
|
73
|
+
if len(results) >= 3:
|
|
74
|
+
break
|
|
75
|
+
if not results:
|
|
76
|
+
return "Поиск не вернул результатов."
|
|
77
|
+
|
|
78
|
+
# Выводим красивое дерево найденных сайтов и сниппетов в консоль пользователя
|
|
79
|
+
try:
|
|
80
|
+
tree = Tree(f"[bold cyan]🔍 Поиск в интернете:[/bold cyan] [white]{query}[/white]")
|
|
81
|
+
for item in results:
|
|
82
|
+
url = str(item.get("href") or item.get("url") or "").strip()
|
|
83
|
+
domain = urlparse(url).netloc.replace("www.", "") or url
|
|
84
|
+
title = str(item.get("title") or domain).strip()
|
|
85
|
+
snippet = " ".join(str(item.get("body") or "").split()).strip()
|
|
86
|
+
if len(snippet) > 130:
|
|
87
|
+
snippet = snippet[:130] + "…"
|
|
88
|
+
|
|
89
|
+
branch = tree.add(f"[bold green]🌐 {domain}[/bold green] — [dim]{title}[/dim]")
|
|
90
|
+
if snippet:
|
|
91
|
+
branch.add(f"[dim italic]«{snippet}»[/dim italic]")
|
|
92
|
+
|
|
93
|
+
console.print()
|
|
94
|
+
console.print(tree)
|
|
95
|
+
console.print()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
lines = []
|
|
100
|
+
for index, item in enumerate(results, 1):
|
|
101
|
+
title = str(item.get("title") or "Без названия").strip()
|
|
102
|
+
url = str(item.get("href") or item.get("url") or "").strip()
|
|
103
|
+
snippet = " ".join(str(item.get("body") or "").split())[:300]
|
|
104
|
+
lines.append(f"{index}. {title}\nURL: {url}\n{snippet}")
|
|
105
|
+
return "\n\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
WEEKDAYS_RU = [
|
|
109
|
+
"понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье",
|
|
110
|
+
]
|
|
111
|
+
MONTHS_RU = [
|
|
112
|
+
"января", "февраля", "марта", "апреля", "мая", "июня",
|
|
113
|
+
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _plural_ru(value, one, few, many):
|
|
118
|
+
value = abs(int(value))
|
|
119
|
+
if value % 10 == 1 and value % 100 != 11:
|
|
120
|
+
return one
|
|
121
|
+
if value % 10 in {2, 3, 4} and value % 100 not in {12, 13, 14}:
|
|
122
|
+
return few
|
|
123
|
+
return many
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _format_datetime_ru(value):
|
|
127
|
+
return (
|
|
128
|
+
f"{WEEKDAYS_RU[value.weekday()]}, {value.day} "
|
|
129
|
+
f"{MONTHS_RU[value.month - 1]} {value.year} года, "
|
|
130
|
+
f"{value:%H:%M:%S}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_offset(days, hours, minutes, seconds):
|
|
135
|
+
parts = []
|
|
136
|
+
if days:
|
|
137
|
+
parts.append(f"{abs(days)} {_plural_ru(days, 'день', 'дня', 'дней')}")
|
|
138
|
+
if hours:
|
|
139
|
+
parts.append(f"{abs(hours)} {_plural_ru(hours, 'час', 'часа', 'часов')}")
|
|
140
|
+
if minutes:
|
|
141
|
+
parts.append(f"{abs(minutes)} {_plural_ru(minutes, 'минута', 'минуты', 'минут')}")
|
|
142
|
+
if seconds:
|
|
143
|
+
parts.append(f"{abs(seconds)} {_plural_ru(seconds, 'секунда', 'секунды', 'секунд')}")
|
|
144
|
+
return " ".join(parts) or "0 минут"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def calculate_datetime(
|
|
148
|
+
days: int = 0,
|
|
149
|
+
hours: int = 0,
|
|
150
|
+
minutes: int = 0,
|
|
151
|
+
seconds: int = 0,
|
|
152
|
+
base_datetime: str = "",
|
|
153
|
+
) -> str:
|
|
154
|
+
"""Точно получить текущую дату/время или вычислить смещение.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
days: Количество дней вперёд (+) или назад (-).
|
|
158
|
+
hours: Количество часов вперёд (+) или назад (-).
|
|
159
|
+
minutes: Количество минут вперёд (+) или назад (-).
|
|
160
|
+
seconds: Количество секунд вперёд (+) или назад (-).
|
|
161
|
+
base_datetime: Необязательная исходная дата ISO 8601. Если пусто — сейчас.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
JSON с готовым полем answer, которое нельзя пересчитывать или изменять.
|
|
165
|
+
"""
|
|
166
|
+
days = int(days)
|
|
167
|
+
hours = int(hours)
|
|
168
|
+
minutes = int(minutes)
|
|
169
|
+
seconds = int(seconds)
|
|
170
|
+
if (
|
|
171
|
+
abs(days) > 365000
|
|
172
|
+
or abs(hours) > 1_000_000
|
|
173
|
+
or abs(minutes) > 10_000_000
|
|
174
|
+
or abs(seconds) > 600_000_000
|
|
175
|
+
):
|
|
176
|
+
return json.dumps(
|
|
177
|
+
{"error": "Слишком большое смещение даты или времени."},
|
|
178
|
+
ensure_ascii=False,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
system_now = datetime.now().astimezone().replace(microsecond=0)
|
|
182
|
+
if base_datetime:
|
|
183
|
+
try:
|
|
184
|
+
now = datetime.fromisoformat(str(base_datetime).strip())
|
|
185
|
+
if now.tzinfo is None:
|
|
186
|
+
now = now.replace(tzinfo=system_now.tzinfo)
|
|
187
|
+
now = now.replace(microsecond=0)
|
|
188
|
+
except ValueError:
|
|
189
|
+
return json.dumps(
|
|
190
|
+
{"error": "Неправильная исходная дата. Используй ISO 8601."},
|
|
191
|
+
ensure_ascii=False,
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
now = system_now
|
|
195
|
+
delta = timedelta(
|
|
196
|
+
days=days,
|
|
197
|
+
hours=hours,
|
|
198
|
+
minutes=minutes,
|
|
199
|
+
seconds=seconds,
|
|
200
|
+
)
|
|
201
|
+
result = now + delta
|
|
202
|
+
offset = now.strftime("%z")
|
|
203
|
+
offset = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
|
|
204
|
+
|
|
205
|
+
current_text = _format_datetime_ru(now)
|
|
206
|
+
base_label = "Исходный момент" if base_datetime else "Сейчас"
|
|
207
|
+
if delta == timedelta(0):
|
|
208
|
+
answer = f"{base_label}: {current_text}."
|
|
209
|
+
else:
|
|
210
|
+
direction = "назад" if delta.total_seconds() < 0 else "вперёд"
|
|
211
|
+
offset_text = _format_offset(days, hours, minutes, seconds)
|
|
212
|
+
answer = (
|
|
213
|
+
f"{base_label}: {current_text}.\n"
|
|
214
|
+
f"{offset_text.capitalize()} {direction}: "
|
|
215
|
+
f"{_format_datetime_ru(result)}."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return json.dumps(
|
|
219
|
+
{
|
|
220
|
+
"current": now.isoformat(),
|
|
221
|
+
"offset": {
|
|
222
|
+
"days": days,
|
|
223
|
+
"hours": hours,
|
|
224
|
+
"minutes": minutes,
|
|
225
|
+
"seconds": seconds,
|
|
226
|
+
},
|
|
227
|
+
"result": result.isoformat(),
|
|
228
|
+
"timezone": "локальный часовой пояс системы",
|
|
229
|
+
"utc_offset": offset,
|
|
230
|
+
"answer": answer,
|
|
231
|
+
"instruction": "Вернуть поле answer дословно без пересчёта.",
|
|
232
|
+
},
|
|
233
|
+
ensure_ascii=False,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_TIME_VALUE_RE = re.compile(
|
|
238
|
+
r"(?P<value>[+-]?\d+)\s*"
|
|
239
|
+
r"(?P<unit>"
|
|
240
|
+
r"д(?:ень|ня|ней)|дн(?:я|ей)?|"
|
|
241
|
+
r"час(?:а|ов)?|ч|"
|
|
242
|
+
r"минут(?:а|ы|у)?|мин|м|"
|
|
243
|
+
r"секунд(?:а|ы|у)?|сек"
|
|
244
|
+
r")(?=\s|$|[,.!?])",
|
|
245
|
+
re.IGNORECASE,
|
|
246
|
+
)
|
|
247
|
+
_MONTH_NUMBER_RU = {name: index + 1 for index, name in enumerate(MONTHS_RU)}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _extract_base_datetime(text):
|
|
251
|
+
normalized = text.lower().replace("ё", "е")
|
|
252
|
+
time_match = re.search(r"\b([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\b", normalized)
|
|
253
|
+
month_pattern = "|".join(_MONTH_NUMBER_RU)
|
|
254
|
+
date_match = re.search(
|
|
255
|
+
rf"\b([0-3]?\d)\s+({month_pattern})(?:\s+(20\d{{2}}))?\b",
|
|
256
|
+
normalized,
|
|
257
|
+
)
|
|
258
|
+
if not time_match and not date_match:
|
|
259
|
+
return ""
|
|
260
|
+
if not date_match and not re.search(r"\b(?:если\s+)?сейчас\b", normalized):
|
|
261
|
+
return ""
|
|
262
|
+
current = datetime.now().astimezone().replace(microsecond=0)
|
|
263
|
+
day = int(date_match.group(1)) if date_match else current.day
|
|
264
|
+
month = _MONTH_NUMBER_RU[date_match.group(2)] if date_match else current.month
|
|
265
|
+
year = int(date_match.group(3)) if date_match and date_match.group(3) else current.year
|
|
266
|
+
hour = int(time_match.group(1)) if time_match else 0
|
|
267
|
+
minute = int(time_match.group(2)) if time_match else 0
|
|
268
|
+
second = int(time_match.group(3) or 0) if time_match else 0
|
|
269
|
+
try:
|
|
270
|
+
base = datetime(year, month, day, hour, minute, second, tzinfo=current.tzinfo)
|
|
271
|
+
except ValueError:
|
|
272
|
+
return ""
|
|
273
|
+
return base.isoformat()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_DIRECT_TIME_WORDS = {
|
|
277
|
+
*_MONTH_NUMBER_RU.keys(),
|
|
278
|
+
"а", "будет", "будут", "будущий", "бы", "был", "была", "было", "были",
|
|
279
|
+
"в", "вперед", "времени", "время", "вычти", "дата", "дату", "день",
|
|
280
|
+
"дней", "дня", "добавь", "если", "и", "какая", "какой", "какое", "какие",
|
|
281
|
+
"который", "год", "месяц", "м", "мин", "минус", "минута", "минут",
|
|
282
|
+
"минуты", "минуту", "на", "назад", "недели", "неделя", "от", "отмотай",
|
|
283
|
+
"отмотать", "плюс", "пожалуйста", "покажи", "посчитай", "прибавь",
|
|
284
|
+
"привет", "скажи", "сколько", "сейчас", "сек", "секунда", "секунд",
|
|
285
|
+
"секунды", "секунду", "сегодня", "тому", "теперь", "точно", "точное",
|
|
286
|
+
"точный", "ровно", "ответь", "назови", "ч", "час", "часа", "часов",
|
|
287
|
+
"через", "число", "что",
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_pure_datetime_request(normalized):
|
|
292
|
+
words = re.findall(r"[a-zа-я]+", normalized)
|
|
293
|
+
return bool(words) and all(word in _DIRECT_TIME_WORDS for word in words)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_datetime_request(text, require_pure=True):
|
|
297
|
+
normalized = " ".join(text.lower().replace("ё", "е").split())
|
|
298
|
+
if len(normalized) > 240:
|
|
299
|
+
return None
|
|
300
|
+
if require_pure and not _is_pure_datetime_request(normalized):
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
matches = list(_TIME_VALUE_RE.finditer(normalized))
|
|
304
|
+
forward = bool(re.search(r"\b(вперед|через|прибав|добав|плюс)\w*", normalized))
|
|
305
|
+
backward = bool(re.search(r"\b(назад|отмот|вычт|минус)\w*", normalized))
|
|
306
|
+
|
|
307
|
+
if matches and (forward or backward or any(
|
|
308
|
+
match.group("value").startswith(("+", "-")) for match in matches
|
|
309
|
+
)):
|
|
310
|
+
direction = 1 if forward else -1 if backward else 1
|
|
311
|
+
values = {"days": 0, "hours": 0, "minutes": 0, "seconds": 0}
|
|
312
|
+
for match in matches:
|
|
313
|
+
raw_value = int(match.group("value"))
|
|
314
|
+
value = abs(raw_value) * direction if (forward or backward) else raw_value
|
|
315
|
+
unit = match.group("unit")
|
|
316
|
+
if unit.startswith("д"):
|
|
317
|
+
values["days"] += value
|
|
318
|
+
elif unit.startswith("ч") or unit.startswith("час"):
|
|
319
|
+
values["hours"] += value
|
|
320
|
+
elif unit.startswith("м"):
|
|
321
|
+
values["minutes"] += value
|
|
322
|
+
else:
|
|
323
|
+
values["seconds"] += value
|
|
324
|
+
base_datetime = _extract_base_datetime(text)
|
|
325
|
+
if base_datetime:
|
|
326
|
+
values["base_datetime"] = base_datetime
|
|
327
|
+
return values
|
|
328
|
+
|
|
329
|
+
bare_offset = re.fullmatch(
|
|
330
|
+
r"(?:а\s+)?(?:если\s+)?(\d+)\s*"
|
|
331
|
+
r"(назад|вперед)[?!.]*",
|
|
332
|
+
normalized,
|
|
333
|
+
)
|
|
334
|
+
if bare_offset:
|
|
335
|
+
hours = int(bare_offset.group(1))
|
|
336
|
+
if bare_offset.group(2) == "назад":
|
|
337
|
+
hours = -hours
|
|
338
|
+
result = {"days": 0, "hours": hours, "minutes": 0, "seconds": 0}
|
|
339
|
+
base_datetime = _extract_base_datetime(text)
|
|
340
|
+
if base_datetime:
|
|
341
|
+
result["base_datetime"] = base_datetime
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
current_time_patterns = [
|
|
345
|
+
r"\bкоторый\s+час\b",
|
|
346
|
+
r"\bсколько\s+(?:сейчас\s+)?времени\b",
|
|
347
|
+
r"\bсколько\s+сейчас\b",
|
|
348
|
+
r"\bкакая\s+(?:сейчас|сегодня)\s+дата\b",
|
|
349
|
+
r"\bкакой\s+сегодня\s+день\b",
|
|
350
|
+
r"\bскажи\s+(?:сегодняшнюю\s+)?дату\b",
|
|
351
|
+
r"\bсегодняшняя\s+дата\b",
|
|
352
|
+
]
|
|
353
|
+
if any(re.search(pattern, normalized) for pattern in current_time_patterns):
|
|
354
|
+
return {"days": 0, "hours": 0, "minutes": 0, "seconds": 0}
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def split_datetime_request(text):
|
|
359
|
+
arguments = parse_datetime_request(text, require_pure=False)
|
|
360
|
+
if arguments is None:
|
|
361
|
+
return None, text
|
|
362
|
+
if parse_datetime_request(text, require_pure=True) is not None:
|
|
363
|
+
return arguments, ""
|
|
364
|
+
|
|
365
|
+
remaining = []
|
|
366
|
+
removed_time_clause = False
|
|
367
|
+
previous_was_time = False
|
|
368
|
+
for piece in re.findall(r"[^?!]+[?!]*", text):
|
|
369
|
+
stripped = piece.strip()
|
|
370
|
+
if not stripped:
|
|
371
|
+
continue
|
|
372
|
+
piece_arguments = parse_datetime_request(stripped, require_pure=True)
|
|
373
|
+
normalized_piece = " ".join(
|
|
374
|
+
stripped.lower().replace("ё", "е").split()
|
|
375
|
+
)
|
|
376
|
+
is_time_format_tail = (
|
|
377
|
+
previous_was_time
|
|
378
|
+
and _is_pure_datetime_request(normalized_piece)
|
|
379
|
+
)
|
|
380
|
+
if piece_arguments is not None or is_time_format_tail:
|
|
381
|
+
removed_time_clause = True
|
|
382
|
+
previous_was_time = True
|
|
383
|
+
continue
|
|
384
|
+
remaining.append(stripped)
|
|
385
|
+
previous_was_time = False
|
|
386
|
+
|
|
387
|
+
if not removed_time_clause:
|
|
388
|
+
cleanup_patterns = [
|
|
389
|
+
r"\bкакая\s+сегодня\s+дата\b",
|
|
390
|
+
r"\bкакое\s+сейчас\s+время\b",
|
|
391
|
+
r"\bсколько\s+сейчас\s+времени\b",
|
|
392
|
+
r"\bскажи\s+(?:сегодняшнюю\s+)?дату\b",
|
|
393
|
+
]
|
|
394
|
+
cleaned = text
|
|
395
|
+
for pattern in cleanup_patterns:
|
|
396
|
+
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
|
|
397
|
+
cleaned = re.sub(r"^\s*[,;:-]?\s*(?:и\s+)?", "", cleaned).strip()
|
|
398
|
+
if cleaned != text.strip():
|
|
399
|
+
return arguments, cleaned
|
|
400
|
+
return None, text
|
|
401
|
+
return arguments, " ".join(remaining).strip()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def datetime_answer(arguments):
|
|
405
|
+
raw_result = calculate_datetime(**arguments)
|
|
406
|
+
try:
|
|
407
|
+
payload = json.loads(raw_result)
|
|
408
|
+
except json.JSONDecodeError:
|
|
409
|
+
return raw_result
|
|
410
|
+
return payload.get("answer") or payload.get("error") or raw_result
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
_BINARY_OPERATORS = {
|
|
414
|
+
ast.Add: operator.add,
|
|
415
|
+
ast.Sub: operator.sub,
|
|
416
|
+
ast.Mult: operator.mul,
|
|
417
|
+
ast.Div: operator.truediv,
|
|
418
|
+
ast.FloorDiv: operator.floordiv,
|
|
419
|
+
ast.Mod: operator.mod,
|
|
420
|
+
ast.Pow: operator.pow,
|
|
421
|
+
}
|
|
422
|
+
_UNARY_OPERATORS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _evaluate_math_node(node):
|
|
426
|
+
if isinstance(node, ast.Expression):
|
|
427
|
+
return _evaluate_math_node(node.body)
|
|
428
|
+
if isinstance(node, ast.Constant) and type(node.value) in {int, float}:
|
|
429
|
+
return node.value
|
|
430
|
+
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPERATORS:
|
|
431
|
+
return _UNARY_OPERATORS[type(node.op)](_evaluate_math_node(node.operand))
|
|
432
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS:
|
|
433
|
+
left = _evaluate_math_node(node.left)
|
|
434
|
+
right = _evaluate_math_node(node.right)
|
|
435
|
+
if isinstance(node.op, ast.Pow) and abs(right) > 1000:
|
|
436
|
+
raise ValueError("Слишком большая степень")
|
|
437
|
+
return _BINARY_OPERATORS[type(node.op)](left, right)
|
|
438
|
+
raise ValueError("Разрешены только числа, скобки и операции + - * / // % **")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def calculate_expression(expression: str) -> str:
|
|
442
|
+
"""Точно вычислить арифметическое выражение без приблизительного счёта.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
expression: Выражение с числами, скобками и операциями + - * / // % **.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Точный результат вычисления или описание ошибки.
|
|
449
|
+
"""
|
|
450
|
+
expression = expression.strip()
|
|
451
|
+
if not expression or len(expression) > 300:
|
|
452
|
+
return "Ошибка: выражение пустое или слишком длинное."
|
|
453
|
+
try:
|
|
454
|
+
tree = ast.parse(expression, mode="eval")
|
|
455
|
+
return str(_evaluate_math_node(tree))
|
|
456
|
+
except Exception as error:
|
|
457
|
+
return f"Ошибка вычисления: {type(error).__name__}: {error}"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
_MATH_ALLOWED_WORDS = {
|
|
461
|
+
"а", "будет", "вычисли", "вычислить", "калькулятором", "математикой",
|
|
462
|
+
"пожалуйста", "посчитай", "проверь", "результат", "сколько", "точно",
|
|
463
|
+
"чему", "равно", "равен", "теперь",
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def parse_math_request(text):
|
|
468
|
+
normalized = text.lower().replace("×", "*").replace("÷", "/")
|
|
469
|
+
if re.search(r"не\s+(?:используй|вызывай).*калькулятор", normalized):
|
|
470
|
+
return None
|
|
471
|
+
candidates = re.findall(r"(?<![\w.])[\d\s().,+\-*/%]{3,}(?![\w.])", normalized)
|
|
472
|
+
candidates = [candidate.strip(" ,.") for candidate in candidates]
|
|
473
|
+
candidates = [
|
|
474
|
+
candidate
|
|
475
|
+
for candidate in candidates
|
|
476
|
+
if re.search(r"[+\-*/%]", candidate) and re.search(r"\d", candidate)
|
|
477
|
+
]
|
|
478
|
+
if not candidates:
|
|
479
|
+
return None
|
|
480
|
+
expression = max(candidates, key=len).replace(",", ".").strip()
|
|
481
|
+
remaining = normalized.replace(max(candidates, key=len), " ")
|
|
482
|
+
words = set(re.findall(r"[a-zа-яё]+", remaining))
|
|
483
|
+
if words and not words <= _MATH_ALLOWED_WORDS:
|
|
484
|
+
return None
|
|
485
|
+
return expression
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def previous_math_expression(messages):
|
|
489
|
+
for message in reversed(messages):
|
|
490
|
+
if message.get("role") != "user":
|
|
491
|
+
continue
|
|
492
|
+
expression = parse_math_request(message.get("content", ""))
|
|
493
|
+
if expression:
|
|
494
|
+
return expression
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def needs_clarification(text):
|
|
499
|
+
normalized = " ".join(text.casefold().strip(" .!?,-").split())
|
|
500
|
+
vague_patterns = [
|
|
501
|
+
r"(?:сделай|повтори|реши|запусти|удали)(?:\s+(?:это|там|так))?(?:\s+как\s+(?:вчера|раньше))?",
|
|
502
|
+
r"(?:я\s+же\s+)?(?:сказал|писал)\s+уже",
|
|
503
|
+
r"сделай\s+как\s+(?:вчера|раньше)",
|
|
504
|
+
]
|
|
505
|
+
return len(normalized) < 80 and any(
|
|
506
|
+
re.fullmatch(pattern, normalized) for pattern in vague_patterns
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def run_python_code(code: str, purpose: str, inputs: str = "") -> str:
|
|
511
|
+
"""Проверить небольшой фрагмент Python-кода на компьютере пользователя.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
code: Полный Python-код для проверки.
|
|
515
|
+
purpose: Коротко: что проверяет код и зачем это нужно для ответа.
|
|
516
|
+
inputs: Необязательно: текстовые строки для input(), через \n (например: "2\n*\n5\n").
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Код завершения, stdout и stderr программы.
|
|
520
|
+
"""
|
|
521
|
+
if not code.strip():
|
|
522
|
+
return "Ошибка: модель передала пустой код."
|
|
523
|
+
if len(code) > 8000:
|
|
524
|
+
return "Ошибка: код длиннее 8000 символов и не будет запущен."
|
|
525
|
+
|
|
526
|
+
purpose = " ".join(str(purpose or "").split())
|
|
527
|
+
if not purpose:
|
|
528
|
+
return "Ошибка: модель не объяснила, зачем ей запускать Python-код."
|
|
529
|
+
|
|
530
|
+
console.print()
|
|
531
|
+
render_message("Зачем нужен Python ›", role="assistant_label")
|
|
532
|
+
render_message(purpose[:600], role="assistant")
|
|
533
|
+
if inputs:
|
|
534
|
+
formatted_inputs = ", ".join(repr(line) for line in str(inputs).splitlines() if line)
|
|
535
|
+
render_message(f"Входные данные для input() › {formatted_inputs}", role="assistant_label")
|
|
536
|
+
console.print()
|
|
537
|
+
console.print(
|
|
538
|
+
Panel(
|
|
539
|
+
Syntax(code, "python", theme="monokai", word_wrap=True, padding=0),
|
|
540
|
+
title="Python просит разрешение на запуск",
|
|
541
|
+
border_style="yellow",
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
console.print(
|
|
545
|
+
"[green]Нажми [bold]Enter[/bold] для запуска. Чтобы отклонить — введи [bold]n[/bold] или [bold]нет[/bold].[/green]"
|
|
546
|
+
)
|
|
547
|
+
try:
|
|
548
|
+
confirmation = session.prompt(
|
|
549
|
+
HTML("<b><ansigreen>Разрешить запуск? [Y/n] › </ansigreen></b>"),
|
|
550
|
+
multiline=False,
|
|
551
|
+
).strip().lower()
|
|
552
|
+
except (KeyboardInterrupt, EOFError):
|
|
553
|
+
confirmation = "n"
|
|
554
|
+
if confirmation in {"n", "no", "н", "нет", "cancel", "отмена"}:
|
|
555
|
+
return "Пользователь отказался запускать код."
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
with tempfile.TemporaryDirectory(prefix="ollama-python-") as temp_dir:
|
|
559
|
+
input_bytes = (str(inputs) + "\n").encode("utf-8") if inputs else None
|
|
560
|
+
completed = subprocess.run(
|
|
561
|
+
[sys.executable, "-I", "-X", "utf8", "-c", code],
|
|
562
|
+
input=input_bytes,
|
|
563
|
+
cwd=temp_dir,
|
|
564
|
+
capture_output=True,
|
|
565
|
+
timeout=10,
|
|
566
|
+
check=False,
|
|
567
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
568
|
+
)
|
|
569
|
+
stdout = completed.stdout.decode("utf-8", errors="replace")[-12000:].rstrip()
|
|
570
|
+
stderr = completed.stderr.decode("utf-8", errors="replace")[-12000:].rstrip()
|
|
571
|
+
return_code = completed.returncode
|
|
572
|
+
except subprocess.TimeoutExpired as error:
|
|
573
|
+
stdout_err = (
|
|
574
|
+
(error.stdout.decode("utf-8", errors="replace") if error.stdout else "")
|
|
575
|
+
+ (error.stderr.decode("utf-8", errors="replace") if error.stderr else "")
|
|
576
|
+
)[-12000:]
|
|
577
|
+
return f"Выполнение остановлено через 10 секунд.\n{stdout_err}"
|
|
578
|
+
except Exception as error:
|
|
579
|
+
return f"Ошибка запуска: {type(error).__name__}: {error}"
|
|
580
|
+
|
|
581
|
+
parts = [f"Код завершения: {return_code}"]
|
|
582
|
+
if stdout:
|
|
583
|
+
parts.append(f"stdout:\n{stdout}")
|
|
584
|
+
if stderr:
|
|
585
|
+
parts.append(f"stderr:\n{stderr}")
|
|
586
|
+
if not stdout and not stderr:
|
|
587
|
+
if return_code == 0:
|
|
588
|
+
parts.append(
|
|
589
|
+
"Программа завершилась успешно (код 0), но в stdout ничего не выведено "
|
|
590
|
+
"(код выполнился без ошибок, но без явного вызова print())."
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
parts.append("Программа завершилась с ошибкой (код != 0) без вывода в stdout.")
|
|
594
|
+
return "\n\n".join(parts)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def set_file_workspace(path_text):
|
|
598
|
+
global ACTIVE_FILE_ROOT
|
|
599
|
+
from . import config
|
|
600
|
+
path = Path(path_text).expanduser().resolve(strict=True)
|
|
601
|
+
if not path.is_dir():
|
|
602
|
+
raise ValueError("Указанный путь не является папкой.")
|
|
603
|
+
config.ACTIVE_FILE_ROOT = path
|
|
604
|
+
return path
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _safe_workspace_path(relative_path):
|
|
608
|
+
from .config import ACTIVE_FILE_ROOT
|
|
609
|
+
if ACTIVE_FILE_ROOT is None:
|
|
610
|
+
raise ValueError("Папка проекта не выбрана. Используй /workspace ПУТЬ.")
|
|
611
|
+
raw_path = Path(str(relative_path or "."))
|
|
612
|
+
candidate = (
|
|
613
|
+
raw_path.resolve(strict=False)
|
|
614
|
+
if raw_path.is_absolute()
|
|
615
|
+
else (ACTIVE_FILE_ROOT / raw_path).resolve(strict=False)
|
|
616
|
+
)
|
|
617
|
+
try:
|
|
618
|
+
candidate.relative_to(ACTIVE_FILE_ROOT)
|
|
619
|
+
except ValueError as error:
|
|
620
|
+
raise ValueError("Путь выходит за пределы выбранной папки проекта.") from error
|
|
621
|
+
return candidate
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def list_project_files(relative_path: str = ".") -> str:
|
|
625
|
+
from .config import ACTIVE_FILE_ROOT
|
|
626
|
+
try:
|
|
627
|
+
directory = _safe_workspace_path(relative_path)
|
|
628
|
+
except Exception as error:
|
|
629
|
+
return f"Ошибка доступа: {error}"
|
|
630
|
+
if not directory.exists() or not directory.is_dir():
|
|
631
|
+
return "Ошибка: папка не существует."
|
|
632
|
+
files = []
|
|
633
|
+
for path in directory.rglob("*"):
|
|
634
|
+
try:
|
|
635
|
+
resolved = path.resolve(strict=True)
|
|
636
|
+
resolved.relative_to(ACTIVE_FILE_ROOT)
|
|
637
|
+
except (OSError, ValueError):
|
|
638
|
+
continue
|
|
639
|
+
if path.is_file():
|
|
640
|
+
files.append(str(path.relative_to(ACTIVE_FILE_ROOT)))
|
|
641
|
+
if len(files) >= 200:
|
|
642
|
+
break
|
|
643
|
+
if not files:
|
|
644
|
+
return "В выбранной папке файлов не найдено."
|
|
645
|
+
suffix = "\n… список ограничен 200 файлами" if len(files) == 200 else ""
|
|
646
|
+
return "Файлы проекта:\n" + "\n".join(f"- {item}" for item in files) + suffix
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def read_project_file(relative_path: str) -> str:
|
|
650
|
+
from .config import ACTIVE_FILE_ROOT
|
|
651
|
+
try:
|
|
652
|
+
path = _safe_workspace_path(relative_path)
|
|
653
|
+
except Exception as error:
|
|
654
|
+
return f"Ошибка доступа: {error}"
|
|
655
|
+
if not path.exists() or not path.is_file():
|
|
656
|
+
return "Ошибка: файл не существует."
|
|
657
|
+
if path.stat().st_size > 2_000_000:
|
|
658
|
+
return "Ошибка: файл больше 2 МБ и не будет прочитан."
|
|
659
|
+
try:
|
|
660
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
661
|
+
except OSError as error:
|
|
662
|
+
return f"Ошибка чтения: {error}"
|
|
663
|
+
if "\x00" in text[:2000]:
|
|
664
|
+
return "Ошибка: файл похож на бинарный."
|
|
665
|
+
if len(text) > 16000:
|
|
666
|
+
text = text[:16000] + "\n\n… файл обрезан до 16000 символов"
|
|
667
|
+
return f"Файл: {path.relative_to(ACTIVE_FILE_ROOT)}\n\n{text}"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def project_files(action: str, path: str = ".") -> str:
|
|
671
|
+
action = action.strip().casefold()
|
|
672
|
+
if action == "list":
|
|
673
|
+
return list_project_files(path)
|
|
674
|
+
if action == "read":
|
|
675
|
+
return read_project_file(path)
|
|
676
|
+
return "Ошибка: action должен быть list или read."
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
_SECRET_PATTERNS = [
|
|
680
|
+
re.compile(
|
|
681
|
+
r"(?i)\b(api[_-]?key|secret|token|password|passwd|authorization)\b"
|
|
682
|
+
r"\s*[:=]\s*([^\s,;]{8,})"
|
|
683
|
+
),
|
|
684
|
+
re.compile(r"\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{16,}\b"),
|
|
685
|
+
re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----"),
|
|
686
|
+
]
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _redact_secrets(text):
|
|
690
|
+
redacted = str(text)
|
|
691
|
+
for pattern in _SECRET_PATTERNS:
|
|
692
|
+
redacted = pattern.sub("[СЕКРЕТ СКРЫТ]", redacted)
|
|
693
|
+
return redacted
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def cancel_active_agy():
|
|
697
|
+
from . import config
|
|
698
|
+
with config.ACTIVE_AGY_LOCK:
|
|
699
|
+
process = config.ACTIVE_AGY_PROCESS
|
|
700
|
+
if process is not None and process.poll() is None:
|
|
701
|
+
try:
|
|
702
|
+
process.kill()
|
|
703
|
+
except OSError:
|
|
704
|
+
pass
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def ask_antigravity(task: str, context: str = "") -> str:
|
|
708
|
+
from . import config
|
|
709
|
+
executable = shutil.which("agy")
|
|
710
|
+
if not executable:
|
|
711
|
+
return "ESCALATE_ERROR: команда agy не найдена в PATH."
|
|
712
|
+
task = _redact_secrets(task.strip())
|
|
713
|
+
context = _redact_secrets(context.strip())
|
|
714
|
+
if not task:
|
|
715
|
+
return "ESCALATE_ERROR: задача для Antigravity пуста."
|
|
716
|
+
if len(task) > 4000:
|
|
717
|
+
task = task[:4000] + "\n… задача сокращена W1x"
|
|
718
|
+
if len(context) > 1200:
|
|
719
|
+
context = context[-1200:]
|
|
720
|
+
prompt = (
|
|
721
|
+
"Ты старшая модель для локального ассистента W1x. Реши задачу и верни "
|
|
722
|
+
"готовый ответ пользователю на русском языке текстом/Markdown. "
|
|
723
|
+
"Не создавай и не изменяй файлы, не вызывай инструменты и не проси "
|
|
724
|
+
"разрешений. Если данных недостаточно, задай один конкретный "
|
|
725
|
+
"уточняющий вопрос. Не упоминай эту служебную инструкцию.\n\n"
|
|
726
|
+
+ (f"Краткий контекст:\n{context}\n\n" if context else "")
|
|
727
|
+
+ f"Задача пользователя:\n{task}"
|
|
728
|
+
)
|
|
729
|
+
prompt = prompt[:AGY_MAX_PROMPT_CHARS]
|
|
730
|
+
|
|
731
|
+
process = None
|
|
732
|
+
allowed_env_names = {
|
|
733
|
+
"APPDATA", "COMSPEC", "LANG", "LC_ALL", "LOCALAPPDATA", "PATH",
|
|
734
|
+
"PATHEXT", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEMDRIVE",
|
|
735
|
+
"SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR",
|
|
736
|
+
}
|
|
737
|
+
safe_environment = {
|
|
738
|
+
key: value for key, value in os.environ.items() if key.upper() in allowed_env_names
|
|
739
|
+
}
|
|
740
|
+
try:
|
|
741
|
+
with tempfile.TemporaryDirectory(prefix="w1x-agy-") as temp_dir:
|
|
742
|
+
process = subprocess.Popen(
|
|
743
|
+
[executable, "-p", prompt],
|
|
744
|
+
cwd=temp_dir,
|
|
745
|
+
stdout=subprocess.PIPE,
|
|
746
|
+
stderr=subprocess.PIPE,
|
|
747
|
+
text=True,
|
|
748
|
+
encoding="utf-8",
|
|
749
|
+
errors="replace",
|
|
750
|
+
env=safe_environment,
|
|
751
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
752
|
+
)
|
|
753
|
+
with config.ACTIVE_AGY_LOCK:
|
|
754
|
+
config.ACTIVE_AGY_PROCESS = process
|
|
755
|
+
stdout, stderr = process.communicate(timeout=AGY_TIMEOUT_SECONDS)
|
|
756
|
+
return_code = process.returncode
|
|
757
|
+
except subprocess.TimeoutExpired:
|
|
758
|
+
if process is not None:
|
|
759
|
+
process.kill()
|
|
760
|
+
stdout, stderr = process.communicate()
|
|
761
|
+
return f"ESCALATE_ERROR: Antigravity не ответила за {AGY_TIMEOUT_SECONDS} секунд."
|
|
762
|
+
except (OSError, PermissionError) as error:
|
|
763
|
+
return f"ESCALATE_ERROR: не удалось запустить agy: {error}"
|
|
764
|
+
finally:
|
|
765
|
+
with config.ACTIVE_AGY_LOCK:
|
|
766
|
+
if config.ACTIVE_AGY_PROCESS is process:
|
|
767
|
+
config.ACTIVE_AGY_PROCESS = None
|
|
768
|
+
|
|
769
|
+
output = (stdout or "").strip()
|
|
770
|
+
error_text = (stderr or "").strip()
|
|
771
|
+
combined_error = f"{output}\n{error_text}".lower()
|
|
772
|
+
if "permission" in combined_error and ("denied" in combined_error or "failed" in combined_error):
|
|
773
|
+
return "ESCALATE_ERROR: Antigravity запросила запрещённое действие; доступ не выдан."
|
|
774
|
+
if return_code != 0:
|
|
775
|
+
details = (error_text or output or "без описания")[-1200:]
|
|
776
|
+
return f"ESCALATE_ERROR: agy завершилась с кодом {return_code}: {details}"
|
|
777
|
+
if not output:
|
|
778
|
+
return "ESCALATE_ERROR: Antigravity не вернула текст."
|
|
779
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
780
|
+
output = ansi_escape.sub("", output)
|
|
781
|
+
if len(output) > AGY_MAX_OUTPUT_CHARS:
|
|
782
|
+
output = output[:AGY_MAX_OUTPUT_CHARS]
|
|
783
|
+
if output.count("```") % 2:
|
|
784
|
+
output += "\n```"
|
|
785
|
+
output += "\n\n> ⚠️ Ответ Antigravity обрезан по лимиту W1x."
|
|
786
|
+
return output
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def should_auto_escalate(text):
|
|
790
|
+
normalized = " ".join(text.casefold().split())
|
|
791
|
+
if re.search(r"\b(?:agy|антигравити)\b", normalized):
|
|
792
|
+
return True
|
|
793
|
+
explicit = re.search(
|
|
794
|
+
r"\b(?:передай|эскалируй|спроси|отдай)\w*\b.*"
|
|
795
|
+
r"\b(?:старш|сильн|другой)\w*\s+(?:ии|модел)",
|
|
796
|
+
normalized,
|
|
797
|
+
)
|
|
798
|
+
if explicit:
|
|
799
|
+
return True
|
|
800
|
+
large_code_task = (
|
|
801
|
+
re.search(r"\b(?:проект|кодовая\s+база|репозитор)\w*\b", normalized)
|
|
802
|
+
and re.search(
|
|
803
|
+
r"\b(?:архитектур|рефактор|race\s*condition|гонк|аудит|"
|
|
804
|
+
r"диагност|миграц|безопасност)\w*\b",
|
|
805
|
+
normalized,
|
|
806
|
+
)
|
|
807
|
+
and re.search(r"\b(?:найди|исправ|спроект|проанализ|сделай|проверь)\w*\b", normalized)
|
|
808
|
+
)
|
|
809
|
+
many_files = re.search(r"\b(?:[5-9]\d|\d{3,})\s+файл", normalized)
|
|
810
|
+
return bool(large_code_task or many_files)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def recent_context_for_agy(messages):
|
|
814
|
+
recent = []
|
|
815
|
+
for message in messages:
|
|
816
|
+
if message.get("role") in {"user", "assistant"}:
|
|
817
|
+
recent.append(f"{message['role']}: {message.get('content', '')}")
|
|
818
|
+
return "\n".join(recent[-3:])[-1200:]
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def run_agy_for_user(task, messages):
|
|
822
|
+
render_message("Передаю задачу старшей модели Antigravity", role="tool")
|
|
823
|
+
result = run_with_spinner(
|
|
824
|
+
"Antigravity думает...",
|
|
825
|
+
lambda: ask_antigravity(task, recent_context_for_agy(messages)),
|
|
826
|
+
)
|
|
827
|
+
render_message("Antigravity ›", role="assistant_label")
|
|
828
|
+
render_message(result, role="assistant")
|
|
829
|
+
return result
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
AVAILABLE_TOOLS = {
|
|
833
|
+
"search_web": search_web,
|
|
834
|
+
"calculate_datetime": calculate_datetime,
|
|
835
|
+
"calculate_expression": calculate_expression,
|
|
836
|
+
"run_python_code": run_python_code,
|
|
837
|
+
"memory": memory,
|
|
838
|
+
"project_files": project_files,
|
|
839
|
+
"ask_antigravity": ask_antigravity,
|
|
840
|
+
}
|
|
841
|
+
TOOL_FUNCTIONS = list(AVAILABLE_TOOLS.values())
|
|
842
|
+
|
|
843
|
+
DIRECT_PASSTHROUGH_TOOLS = {
|
|
844
|
+
"calculate_expression",
|
|
845
|
+
"project_files",
|
|
846
|
+
"ask_antigravity",
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def format_direct_tool_result(name, result):
|
|
851
|
+
if name == "calculate_datetime":
|
|
852
|
+
try:
|
|
853
|
+
payload = json.loads(result)
|
|
854
|
+
return payload.get("answer") or result
|
|
855
|
+
except json.JSONDecodeError:
|
|
856
|
+
return result
|
|
857
|
+
return result
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def execute_tool_call(call):
|
|
861
|
+
name = call.function.name
|
|
862
|
+
arguments = call.function.arguments or {}
|
|
863
|
+
if isinstance(arguments, str):
|
|
864
|
+
try:
|
|
865
|
+
arguments = json.loads(arguments)
|
|
866
|
+
except json.JSONDecodeError:
|
|
867
|
+
return f"Инструмент {name} получил неправильные аргументы: {arguments}"
|
|
868
|
+
if not isinstance(arguments, dict):
|
|
869
|
+
return f"Инструмент {name} получил аргументы неправильного типа."
|
|
870
|
+
function = AVAILABLE_TOOLS.get(name)
|
|
871
|
+
if function is None:
|
|
872
|
+
return f"Неизвестный инструмент: {name}"
|
|
873
|
+
|
|
874
|
+
labels = {
|
|
875
|
+
"search_web": f"Ищу в интернете: {arguments.get('query', '')}",
|
|
876
|
+
"calculate_datetime": "Вычисляю точные дату и время",
|
|
877
|
+
"calculate_expression": f"Считаю: {arguments.get('expression', '')}",
|
|
878
|
+
"run_python_code": "Модель хочет проверить Python-код",
|
|
879
|
+
"memory": f"Постоянная память: {arguments.get('action', '')}",
|
|
880
|
+
"project_files": f"Файлы проекта, только чтение: {arguments.get('action', '')}",
|
|
881
|
+
"ask_antigravity": "Передаю сложную задачу Antigravity",
|
|
882
|
+
}
|
|
883
|
+
console.print()
|
|
884
|
+
render_message(labels.get(name, name), role="tool")
|
|
885
|
+
try:
|
|
886
|
+
if name == "ask_antigravity":
|
|
887
|
+
return str(
|
|
888
|
+
run_with_spinner(
|
|
889
|
+
"Antigravity думает...",
|
|
890
|
+
lambda: function(**arguments),
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
return str(function(**arguments))
|
|
894
|
+
except KeyboardInterrupt:
|
|
895
|
+
return "Вызов инструмента остановлен пользователем."
|
|
896
|
+
except Exception as error:
|
|
897
|
+
return f"Ошибка инструмента {name}: {type(error).__name__}: {error}"
|