redsba-code 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.
- redsba/__init__.py +1 -0
- redsba/__main__.py +12 -0
- redsba/app.py +262 -0
- redsba/chats.py +40 -0
- redsba/config.py +50 -0
- redsba/providers.py +169 -0
- redsba/skills.py +94 -0
- redsba/tools.py +289 -0
- redsba/ui.py +89 -0
- redsba_code-0.1.0.dist-info/METADATA +22 -0
- redsba_code-0.1.0.dist-info/RECORD +14 -0
- redsba_code-0.1.0.dist-info/WHEEL +5 -0
- redsba_code-0.1.0.dist-info/entry_points.txt +2 -0
- redsba_code-0.1.0.dist-info/top_level.txt +1 -0
redsba/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
redsba/__main__.py
ADDED
redsba/app.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from prompt_toolkit import prompt
|
|
2
|
+
|
|
3
|
+
from redsba.config import (ROLES, SLOT_COUNT, empty_slot, find_brain, load_config,
|
|
4
|
+
mask, save_config, slot_ready)
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from redsba import chats, skills, tools
|
|
8
|
+
from redsba.providers import PROVIDERS, TEMPLATES, ask, chat_tools, list_gemini_models
|
|
9
|
+
from redsba.ui import clear, menu, spinner
|
|
10
|
+
|
|
11
|
+
BANNER = """██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
12
|
+
██╔══██╗██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
13
|
+
██████╔╝███████╗ ██║ ██║ ██║██║ ██║█████╗
|
|
14
|
+
██╔══██╗╚════██║ ██║ ██║ ██║██║ ██║██╔══╝
|
|
15
|
+
██║ ██║███████║ ╚██████╗╚██████╔╝██████╔╝███████╗
|
|
16
|
+
╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝"""
|
|
17
|
+
|
|
18
|
+
MAX_STEPS = 20
|
|
19
|
+
SYSTEM_PROMPT = (
|
|
20
|
+
"Ты агент в терминале redSBA code. Рабочая папка: {root}. "
|
|
21
|
+
"Для работы с файлами и командами используй инструменты. "
|
|
22
|
+
"Перед правкой файла сначала прочитай его. Если нужен совет, используй ask_helper "
|
|
23
|
+
"(не больше трёх раз). Отвечай кратко и по делу."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def start_screen():
|
|
28
|
+
clear()
|
|
29
|
+
print("redSBA code\n")
|
|
30
|
+
items = ["chatting", "history", "setting", "exit"]
|
|
31
|
+
choice = menu("Главное меню", items)
|
|
32
|
+
return items[choice] if choice is not None else "exit"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def history_screen():
|
|
36
|
+
"""Список сохранённых чатов. Возвращает выбранный чат или None."""
|
|
37
|
+
saved = chats.list_chats()
|
|
38
|
+
if not saved:
|
|
39
|
+
clear()
|
|
40
|
+
print("Сохранённых чатов пока нет.")
|
|
41
|
+
input("Enter, чтобы вернуться...")
|
|
42
|
+
return None
|
|
43
|
+
items = [f"{c['updated']} {c['title']}" for c in saved] + ["назад"]
|
|
44
|
+
pick = menu("История чатов", items)
|
|
45
|
+
if pick is None or pick == len(saved):
|
|
46
|
+
return None
|
|
47
|
+
return saved[pick]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pick_gemini_model(slot):
|
|
51
|
+
"""Загружает список моделей Gemini с Google и даёт выбрать стрелками."""
|
|
52
|
+
if not slot["api_key"]:
|
|
53
|
+
print("Сначала введите ключ Gemini.")
|
|
54
|
+
input("Enter...")
|
|
55
|
+
return
|
|
56
|
+
clear()
|
|
57
|
+
print("Загружаю список моделей...")
|
|
58
|
+
try:
|
|
59
|
+
models = list_gemini_models(slot["api_key"])
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Не удалось получить список: {e}")
|
|
62
|
+
input("Enter...")
|
|
63
|
+
return
|
|
64
|
+
if not models:
|
|
65
|
+
print("Google не вернул ни одной модели для этого ключа.")
|
|
66
|
+
input("Enter...")
|
|
67
|
+
return
|
|
68
|
+
pick = menu("Модель Gemini", models)
|
|
69
|
+
if pick is not None:
|
|
70
|
+
slot["model"] = models[pick]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def edit_slot(cfg, idx):
|
|
74
|
+
slot = cfg["slots"][idx]
|
|
75
|
+
while True:
|
|
76
|
+
clear()
|
|
77
|
+
print(f"Настройка {slot['name']}\n")
|
|
78
|
+
items = [
|
|
79
|
+
f"провайдер: {slot['provider']}",
|
|
80
|
+
f"роль: {slot['role']}",
|
|
81
|
+
f"ключ: {mask(slot['api_key'])}",
|
|
82
|
+
f"base_url: {slot['base_url'] or '(нужен для openai)'}",
|
|
83
|
+
f"модель: {slot['model'] or '(пусто, введите вручную)'}",
|
|
84
|
+
"шаблон (Groq, OpenRouter...)",
|
|
85
|
+
"очистить слот",
|
|
86
|
+
"назад",
|
|
87
|
+
]
|
|
88
|
+
choice = menu(slot["name"], items)
|
|
89
|
+
if choice is None or choice == 7:
|
|
90
|
+
save_config(cfg)
|
|
91
|
+
return
|
|
92
|
+
if choice == 0:
|
|
93
|
+
slot["provider"] = PROVIDERS[(PROVIDERS.index(slot["provider"]) + 1) % len(PROVIDERS)]
|
|
94
|
+
elif choice == 1:
|
|
95
|
+
slot["role"] = ROLES[(ROLES.index(slot["role"]) + 1) % len(ROLES)]
|
|
96
|
+
elif choice == 2:
|
|
97
|
+
slot["api_key"] = prompt("API-ключ: ", is_password=True).strip()
|
|
98
|
+
elif choice == 3:
|
|
99
|
+
slot["base_url"] = prompt("base_url: ", default=slot["base_url"]).strip()
|
|
100
|
+
elif choice == 4:
|
|
101
|
+
if slot["provider"] == "gemini":
|
|
102
|
+
pick_gemini_model(slot)
|
|
103
|
+
else:
|
|
104
|
+
value = prompt("модель: ", default=slot["model"]).strip()
|
|
105
|
+
if value:
|
|
106
|
+
slot["model"] = value
|
|
107
|
+
else:
|
|
108
|
+
print("Модель не может быть пустой.")
|
|
109
|
+
input("Enter...")
|
|
110
|
+
elif choice == 5:
|
|
111
|
+
names = list(TEMPLATES)
|
|
112
|
+
pick = menu("Шаблон", names)
|
|
113
|
+
if pick is not None:
|
|
114
|
+
prov, url = TEMPLATES[names[pick]]
|
|
115
|
+
slot["provider"], slot["base_url"], slot["model"] = prov, url, ""
|
|
116
|
+
elif choice == 6:
|
|
117
|
+
cfg["slots"][idx] = empty_slot(idx + 1)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def settings_screen(cfg):
|
|
122
|
+
while True:
|
|
123
|
+
clear()
|
|
124
|
+
items = []
|
|
125
|
+
for s in cfg["slots"]:
|
|
126
|
+
state = "готов" if slot_ready(s) else "не настроен"
|
|
127
|
+
items.append(f"{s['name']}: {s['provider']}, {s['role']}, {state}")
|
|
128
|
+
items.append("назад")
|
|
129
|
+
choice = menu("Настройки", items)
|
|
130
|
+
if choice is None or choice == len(items) - 1:
|
|
131
|
+
save_config(cfg)
|
|
132
|
+
return
|
|
133
|
+
edit_slot(cfg, choice)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def agent_turn(brain, system, history):
|
|
137
|
+
"""Крутит модель, пока она вызывает инструменты, и выводит итоговый ответ."""
|
|
138
|
+
for _ in range(MAX_STEPS):
|
|
139
|
+
with spinner("Думаю"):
|
|
140
|
+
reply = chat_tools(brain, system, history, tools.SPECS)
|
|
141
|
+
history.append({"role": "assistant", "content": reply["content"],
|
|
142
|
+
"tool_calls": reply["tool_calls"]})
|
|
143
|
+
if reply["content"]:
|
|
144
|
+
print(reply["content"])
|
|
145
|
+
if not reply["tool_calls"]:
|
|
146
|
+
return
|
|
147
|
+
for call in reply["tool_calls"]:
|
|
148
|
+
print(f"\n[инструмент] {call['name']} {json.dumps(call['args'], ensure_ascii=False)[:200]}")
|
|
149
|
+
result = tools.execute(call["name"], call["args"])
|
|
150
|
+
history.append({"role": "tool", "tool_call_id": call["id"],
|
|
151
|
+
"name": call["name"], "content": result})
|
|
152
|
+
print(f"\nЛимит шагов ({MAX_STEPS}) достигнут. Напишите, как продолжить.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def skill_menu():
|
|
156
|
+
"""Выбор навыка из домашней папки. Выбранный навык добавляется к промпту."""
|
|
157
|
+
found = skills.find_skills()
|
|
158
|
+
if not found:
|
|
159
|
+
print("В домашней папке не найдено папок с SKILL.md.")
|
|
160
|
+
print("Положите навык в папку, например ~/мой_навык/SKILL.md")
|
|
161
|
+
return
|
|
162
|
+
items = [f"{s['name']}: {s['description'][:60]}" for s in found]
|
|
163
|
+
pick = menu("Выберите навык", items)
|
|
164
|
+
if pick is None:
|
|
165
|
+
return
|
|
166
|
+
chosen = found[pick]
|
|
167
|
+
if any(s["path"] == chosen["path"] for s in skills.ACTIVE):
|
|
168
|
+
print("Этот навык уже включён.")
|
|
169
|
+
return
|
|
170
|
+
skills.ACTIVE.append(chosen)
|
|
171
|
+
print(f"Навык включён: {chosen['name']}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def chat_screen(cfg, chat_id=None, history=None):
|
|
175
|
+
brain = find_brain(cfg)
|
|
176
|
+
clear()
|
|
177
|
+
if not brain:
|
|
178
|
+
print("Нет слота с ролью brain. Откройте setting и настройте один.")
|
|
179
|
+
input("Enter, чтобы вернуться...")
|
|
180
|
+
return
|
|
181
|
+
system = SYSTEM_PROMPT.format(root=tools.ROOT)
|
|
182
|
+
tools.CONFIG["slots"] = cfg["slots"]
|
|
183
|
+
history = list(history) if history else []
|
|
184
|
+
chat_id = chat_id or chats.new_id()
|
|
185
|
+
def header():
|
|
186
|
+
clear()
|
|
187
|
+
print(BANNER)
|
|
188
|
+
print()
|
|
189
|
+
print(brain["model"])
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
header()
|
|
193
|
+
while True:
|
|
194
|
+
text = prompt("> ").strip()
|
|
195
|
+
if not text:
|
|
196
|
+
continue
|
|
197
|
+
if text == "/exit":
|
|
198
|
+
return
|
|
199
|
+
if text == "/clear":
|
|
200
|
+
# очищает только экран, история диалога остаётся
|
|
201
|
+
header()
|
|
202
|
+
continue
|
|
203
|
+
if text == "/new":
|
|
204
|
+
history.clear()
|
|
205
|
+
chat_id = chats.new_id()
|
|
206
|
+
header()
|
|
207
|
+
print("Начат новый чат.")
|
|
208
|
+
continue
|
|
209
|
+
if text == "/skill":
|
|
210
|
+
skill_menu()
|
|
211
|
+
if skills.ACTIVE:
|
|
212
|
+
print("Активные навыки: " + ", ".join(s["name"] for s in skills.ACTIVE))
|
|
213
|
+
continue
|
|
214
|
+
if text == "/skill clear":
|
|
215
|
+
skills.ACTIVE.clear()
|
|
216
|
+
print("Навыки отключены.")
|
|
217
|
+
continue
|
|
218
|
+
if text.startswith("/ask "):
|
|
219
|
+
parts = text.split(maxsplit=2)
|
|
220
|
+
if len(parts) < 3 or not parts[1].isdigit() or not 0 < int(parts[1]) <= SLOT_COUNT:
|
|
221
|
+
print("Формат: /ask <номер слота> <вопрос>")
|
|
222
|
+
continue
|
|
223
|
+
helper = cfg["slots"][int(parts[1]) - 1]
|
|
224
|
+
if helper["role"] != "helper" or not slot_ready(helper):
|
|
225
|
+
print("Этот слот не готов или не имеет роль helper.")
|
|
226
|
+
continue
|
|
227
|
+
try:
|
|
228
|
+
with spinner("Спрашиваю помощника"):
|
|
229
|
+
answer = ask(helper, parts[2])
|
|
230
|
+
print(answer)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
print(f"Ошибка: {e}")
|
|
233
|
+
continue
|
|
234
|
+
history.append({"role": "user", "content": text})
|
|
235
|
+
tools.HELPER_CALLS[0] = 0
|
|
236
|
+
mark = len(history)
|
|
237
|
+
try:
|
|
238
|
+
agent_turn(brain, skills.build_system(system), history)
|
|
239
|
+
chats.save(chat_id, history)
|
|
240
|
+
except KeyboardInterrupt:
|
|
241
|
+
del history[mark - 1:]
|
|
242
|
+
print("\nПрервано.")
|
|
243
|
+
except Exception as e:
|
|
244
|
+
del history[mark - 1:]
|
|
245
|
+
print(f"Ошибка: {e}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run():
|
|
249
|
+
cfg = load_config()
|
|
250
|
+
while True:
|
|
251
|
+
action = start_screen()
|
|
252
|
+
if action == "chatting":
|
|
253
|
+
chat_screen(cfg)
|
|
254
|
+
elif action == "history":
|
|
255
|
+
saved = history_screen()
|
|
256
|
+
if saved:
|
|
257
|
+
chat_screen(cfg, saved["id"], saved["history"])
|
|
258
|
+
elif action == "setting":
|
|
259
|
+
settings_screen(cfg)
|
|
260
|
+
else:
|
|
261
|
+
clear()
|
|
262
|
+
return
|
redsba/chats.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Сохранение чатов в ~/.redsba/chats/ как отдельных JSON-файлов."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
CHATS_DIR = Path.home() / ".redsba" / "chats"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def new_id():
|
|
11
|
+
return time.strftime("%Y%m%d-%H%M%S")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def save(chat_id, history):
|
|
15
|
+
"""Сохраняет чат. Ключи API сюда не попадают: только сообщения."""
|
|
16
|
+
first = next((m["content"] for m in history if m["role"] == "user"), "")
|
|
17
|
+
title = " ".join(first.split())[:60] or "(пусто)"
|
|
18
|
+
data = {
|
|
19
|
+
"id": chat_id,
|
|
20
|
+
"title": title,
|
|
21
|
+
"updated": time.strftime("%Y-%m-%d %H:%M"),
|
|
22
|
+
"history": history,
|
|
23
|
+
}
|
|
24
|
+
CHATS_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
path = CHATS_DIR / f"{chat_id}.json"
|
|
26
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
27
|
+
path.chmod(0o600)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_chats():
|
|
31
|
+
"""Возвращает сохранённые чаты, новые сверху."""
|
|
32
|
+
result = []
|
|
33
|
+
if not CHATS_DIR.exists():
|
|
34
|
+
return result
|
|
35
|
+
for p in sorted(CHATS_DIR.glob("*.json"), reverse=True):
|
|
36
|
+
try:
|
|
37
|
+
result.append(json.loads(p.read_text(encoding="utf-8")))
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
continue
|
|
40
|
+
return result
|
redsba/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
CONFIG_DIR = Path.home() / ".redsba"
|
|
6
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
7
|
+
SLOT_COUNT = 3
|
|
8
|
+
ROLES = ["off", "brain", "helper"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def empty_slot(n):
|
|
12
|
+
return {"name": f"slot {n}", "provider": "openai", "api_key": "",
|
|
13
|
+
"base_url": "", "model": "", "role": "off"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config():
|
|
17
|
+
data = {}
|
|
18
|
+
if CONFIG_FILE.exists():
|
|
19
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
20
|
+
slots = data.get("slots", [])
|
|
21
|
+
while len(slots) < SLOT_COUNT:
|
|
22
|
+
slots.append(empty_slot(len(slots) + 1))
|
|
23
|
+
return {"slots": slots[:SLOT_COUNT]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_config(cfg):
|
|
27
|
+
CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
28
|
+
CONFIG_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
29
|
+
os.chmod(CONFIG_FILE, 0o600)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def mask(key):
|
|
33
|
+
if not key:
|
|
34
|
+
return "(пусто)"
|
|
35
|
+
return key[:4] + "****" + key[-4:] if len(key) > 8 else "****"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def slot_ready(slot):
|
|
39
|
+
if not slot["api_key"] or not slot["model"] or slot["role"] == "off":
|
|
40
|
+
return False
|
|
41
|
+
if slot["provider"] == "openai":
|
|
42
|
+
return bool(slot["base_url"])
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_brain(cfg):
|
|
47
|
+
for s in cfg["slots"]:
|
|
48
|
+
if s["role"] == "brain" and slot_ready(s):
|
|
49
|
+
return s
|
|
50
|
+
return None
|
redsba/providers.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
TEMPLATES = {
|
|
6
|
+
"Groq": ("openai", "https://api.groq.com/openai/v1"),
|
|
7
|
+
"OpenRouter": ("openai", "https://openrouter.ai/api/v1"),
|
|
8
|
+
"Mistral": ("openai", "https://api.mistral.ai/v1"),
|
|
9
|
+
"DeepSeek": ("openai", "https://api.deepseek.com/v1"),
|
|
10
|
+
"Gemini": ("gemini", ""),
|
|
11
|
+
}
|
|
12
|
+
PROVIDERS = ["openai", "gemini"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HINTS = {
|
|
16
|
+
400: "запрос отклонён, проверьте модель и параметры",
|
|
17
|
+
401: "неверный ключ API",
|
|
18
|
+
403: "доступ запрещён, проверьте ключ и оплату проекта",
|
|
19
|
+
404: "модель не найдена, проверьте её имя",
|
|
20
|
+
413: "запрос слишком большой для лимита, сократите текст или выберите другую модель",
|
|
21
|
+
429: "превышен лимит запросов, подождите минуту",
|
|
22
|
+
500: "внутренняя ошибка сервера, попробуйте позже",
|
|
23
|
+
503: "модель сейчас перегружена, попробуйте через минуту",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _raise(r):
|
|
28
|
+
"""Превращает HTTP-ошибку в обычный текст, без JSON."""
|
|
29
|
+
if r.status_code < 400:
|
|
30
|
+
return
|
|
31
|
+
detail = ""
|
|
32
|
+
try:
|
|
33
|
+
err = r.json().get("error", {})
|
|
34
|
+
detail = err.get("message", "") if isinstance(err, dict) else str(err)
|
|
35
|
+
except ValueError:
|
|
36
|
+
detail = r.text
|
|
37
|
+
detail = " ".join(detail.split())[:200]
|
|
38
|
+
text = HINTS.get(r.status_code, f"ошибка сервера, код {r.status_code}")
|
|
39
|
+
raise RuntimeError(f"{text}. {detail}" if detail else text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------- OpenAI-совместимый формат ----------
|
|
43
|
+
|
|
44
|
+
def _openai_messages(system, history):
|
|
45
|
+
out = [{"role": "system", "content": system}]
|
|
46
|
+
for m in history:
|
|
47
|
+
if m["role"] == "user":
|
|
48
|
+
out.append({"role": "user", "content": m["content"]})
|
|
49
|
+
elif m["role"] == "assistant":
|
|
50
|
+
d = {"role": "assistant", "content": m.get("content") or None}
|
|
51
|
+
if m.get("tool_calls"):
|
|
52
|
+
d["tool_calls"] = [
|
|
53
|
+
{"id": c["id"], "type": "function",
|
|
54
|
+
"function": {"name": c["name"],
|
|
55
|
+
"arguments": json.dumps(c["args"], ensure_ascii=False)}}
|
|
56
|
+
for c in m["tool_calls"]
|
|
57
|
+
]
|
|
58
|
+
out.append(d)
|
|
59
|
+
elif m["role"] == "tool":
|
|
60
|
+
out.append({"role": "tool", "tool_call_id": m["tool_call_id"],
|
|
61
|
+
"content": m["content"]})
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _chat_openai(slot, system, history, tools):
|
|
66
|
+
url = slot["base_url"].rstrip("/") + "/chat/completions"
|
|
67
|
+
body = {"model": slot["model"], "messages": _openai_messages(system, history)}
|
|
68
|
+
if tools:
|
|
69
|
+
body["tools"] = [{"type": "function", "function": t} for t in tools]
|
|
70
|
+
r = requests.post(url, headers={"Authorization": f"Bearer {slot['api_key']}"},
|
|
71
|
+
json=body, timeout=180)
|
|
72
|
+
_raise(r)
|
|
73
|
+
msg = r.json()["choices"][0]["message"]
|
|
74
|
+
calls = []
|
|
75
|
+
for i, tc in enumerate(msg.get("tool_calls") or []):
|
|
76
|
+
try:
|
|
77
|
+
args = json.loads(tc["function"].get("arguments") or "{}")
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
args = {}
|
|
80
|
+
calls.append({"id": tc.get("id") or f"call_{i}",
|
|
81
|
+
"name": tc["function"]["name"], "args": args})
|
|
82
|
+
return {"content": msg.get("content") or "", "tool_calls": calls}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------- Gemini API ----------
|
|
86
|
+
|
|
87
|
+
def _gemini_contents(history):
|
|
88
|
+
contents = []
|
|
89
|
+
for m in history:
|
|
90
|
+
if m["role"] == "user":
|
|
91
|
+
contents.append({"role": "user", "parts": [{"text": m["content"]}]})
|
|
92
|
+
elif m["role"] == "assistant":
|
|
93
|
+
parts = []
|
|
94
|
+
if m.get("content"):
|
|
95
|
+
parts.append({"text": m["content"]})
|
|
96
|
+
for c in m.get("tool_calls", []):
|
|
97
|
+
# raw сохраняет исходную часть ответа, включая служебные поля Gemini 3
|
|
98
|
+
parts.append(c.get("raw") or {"functionCall": {"name": c["name"], "args": c["args"]}})
|
|
99
|
+
if parts:
|
|
100
|
+
contents.append({"role": "model", "parts": parts})
|
|
101
|
+
elif m["role"] == "tool":
|
|
102
|
+
part = {"functionResponse": {"name": m["name"],
|
|
103
|
+
"response": {"result": m["content"]}}}
|
|
104
|
+
last = contents[-1] if contents else None
|
|
105
|
+
if last and last["role"] == "user" and any("functionResponse" in p for p in last["parts"]):
|
|
106
|
+
last["parts"].append(part)
|
|
107
|
+
else:
|
|
108
|
+
contents.append({"role": "user", "parts": [part]})
|
|
109
|
+
return contents
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _chat_gemini(slot, system, history, tools):
|
|
113
|
+
url = ("https://generativelanguage.googleapis.com/v1beta/models/"
|
|
114
|
+
f"{slot['model']}:generateContent")
|
|
115
|
+
body = {"contents": _gemini_contents(history)}
|
|
116
|
+
if system:
|
|
117
|
+
body["systemInstruction"] = {"parts": [{"text": system}]}
|
|
118
|
+
if tools:
|
|
119
|
+
body["tools"] = [{"functionDeclarations": tools}]
|
|
120
|
+
r = requests.post(url, params={"key": slot["api_key"]}, json=body, timeout=180)
|
|
121
|
+
_raise(r)
|
|
122
|
+
parts = r.json()["candidates"][0].get("content", {}).get("parts", [])
|
|
123
|
+
texts, calls = [], []
|
|
124
|
+
for i, p in enumerate(parts):
|
|
125
|
+
if "functionCall" in p:
|
|
126
|
+
fc = p["functionCall"]
|
|
127
|
+
calls.append({"id": f"gem_{i}", "name": fc["name"],
|
|
128
|
+
"args": fc.get("args", {}), "raw": p})
|
|
129
|
+
elif "text" in p and not p.get("thought"):
|
|
130
|
+
texts.append(p["text"])
|
|
131
|
+
return {"content": "".join(texts), "tool_calls": calls}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------- общие функции ----------
|
|
135
|
+
|
|
136
|
+
def list_gemini_models(api_key):
|
|
137
|
+
"""Возвращает имена текстовых моделей Gemini, доступных этому ключу."""
|
|
138
|
+
r = requests.get(
|
|
139
|
+
"https://generativelanguage.googleapis.com/v1beta/models",
|
|
140
|
+
params={"key": api_key, "pageSize": 1000},
|
|
141
|
+
timeout=30,
|
|
142
|
+
)
|
|
143
|
+
r.raise_for_status()
|
|
144
|
+
names = []
|
|
145
|
+
for m in r.json().get("models", []):
|
|
146
|
+
methods = m.get("supportedGenerationMethods", [])
|
|
147
|
+
name = m.get("name", "").removeprefix("models/")
|
|
148
|
+
if "generateContent" in methods and name.startswith("gemini"):
|
|
149
|
+
names.append(name)
|
|
150
|
+
return sorted(names, reverse=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def chat_tools(slot, system, history, tools=None):
|
|
154
|
+
"""Один запрос к модели. Возвращает {"content": str, "tool_calls": [...]}."""
|
|
155
|
+
try:
|
|
156
|
+
if slot["provider"] == "gemini":
|
|
157
|
+
return _chat_gemini(slot, system, history, tools)
|
|
158
|
+
return _chat_openai(slot, system, history, tools)
|
|
159
|
+
except requests.exceptions.Timeout:
|
|
160
|
+
raise RuntimeError("сервер не ответил вовремя, попробуйте ещё раз")
|
|
161
|
+
except requests.exceptions.ConnectionError:
|
|
162
|
+
raise RuntimeError("обрыв соединения с сервером, попробуйте ещё раз")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ask(slot, question):
|
|
166
|
+
"""Простой вопрос без инструментов, для /ask."""
|
|
167
|
+
reply = chat_tools(slot, "Отвечай кратко и по делу.",
|
|
168
|
+
[{"role": "user", "content": question}])
|
|
169
|
+
return reply["content"]
|
redsba/skills.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Навыки в формате Claude: папки с файлом SKILL.md в домашней папке Termux."""
|
|
2
|
+
|
|
3
|
+
import zipfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
MAX_SKILL_CHARS = 20000
|
|
7
|
+
MAX_DEPTH = 2
|
|
8
|
+
ACTIVE = [] # выбранные навыки: список словарей {name, description, body}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse(text):
|
|
12
|
+
"""Разбирает YAML-шапку между строками ---. Возвращает (meta, body)."""
|
|
13
|
+
meta = {}
|
|
14
|
+
body = text
|
|
15
|
+
if text.startswith("---"):
|
|
16
|
+
parts = text.split("---", 2)
|
|
17
|
+
if len(parts) == 3:
|
|
18
|
+
for line in parts[1].splitlines():
|
|
19
|
+
if ":" in line:
|
|
20
|
+
key, value = line.split(":", 1)
|
|
21
|
+
meta[key.strip()] = value.strip().strip('"\'')
|
|
22
|
+
body = parts[2].strip()
|
|
23
|
+
return meta, body
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _from_zip(path):
|
|
27
|
+
"""Читает SKILL.md из zip-архива: в корне или в одной папке внутри."""
|
|
28
|
+
try:
|
|
29
|
+
with zipfile.ZipFile(path) as z:
|
|
30
|
+
names = [n for n in z.namelist()
|
|
31
|
+
if n.rsplit("/", 1)[-1] == "SKILL.md" and n.count("/") <= 1]
|
|
32
|
+
if not names:
|
|
33
|
+
return None
|
|
34
|
+
names.sort(key=lambda n: n.count("/"))
|
|
35
|
+
with z.open(names[0]) as f:
|
|
36
|
+
# читаем с ограничением, чтобы огромный архив не забил память
|
|
37
|
+
raw = f.read(MAX_SKILL_CHARS * 4)
|
|
38
|
+
except (OSError, zipfile.BadZipFile, KeyError):
|
|
39
|
+
return None
|
|
40
|
+
meta, body = _parse(raw.decode("utf-8", errors="replace"))
|
|
41
|
+
return {
|
|
42
|
+
"name": meta.get("name") or path.stem,
|
|
43
|
+
"description": meta.get("description", ""),
|
|
44
|
+
"body": body[:MAX_SKILL_CHARS],
|
|
45
|
+
"path": str(path),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_skills(base=None):
|
|
50
|
+
"""Ищет папки с SKILL.md до MAX_DEPTH уровней вглубь от базовой папки."""
|
|
51
|
+
base = Path(base or Path.home())
|
|
52
|
+
found = []
|
|
53
|
+
|
|
54
|
+
def walk(folder, depth):
|
|
55
|
+
if depth > MAX_DEPTH:
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
children = sorted(folder.iterdir())
|
|
59
|
+
except OSError:
|
|
60
|
+
return
|
|
61
|
+
for child in children:
|
|
62
|
+
if child.name.startswith("."):
|
|
63
|
+
continue
|
|
64
|
+
if child.is_file() and child.suffix.lower() == ".zip":
|
|
65
|
+
zipped = _from_zip(child)
|
|
66
|
+
if zipped:
|
|
67
|
+
found.append(zipped)
|
|
68
|
+
continue
|
|
69
|
+
if not child.is_dir():
|
|
70
|
+
continue
|
|
71
|
+
skill_file = child / "SKILL.md"
|
|
72
|
+
if skill_file.is_file():
|
|
73
|
+
try:
|
|
74
|
+
meta, body = _parse(skill_file.read_text(encoding="utf-8", errors="replace"))
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
found.append({
|
|
78
|
+
"name": meta.get("name") or child.name,
|
|
79
|
+
"description": meta.get("description", ""),
|
|
80
|
+
"body": body[:MAX_SKILL_CHARS],
|
|
81
|
+
"path": str(child),
|
|
82
|
+
})
|
|
83
|
+
walk(child, depth + 1)
|
|
84
|
+
|
|
85
|
+
walk(base, 1)
|
|
86
|
+
return found
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_system(prompt):
|
|
90
|
+
"""Добавляет тексты выбранных навыков к системному промпту."""
|
|
91
|
+
if not ACTIVE:
|
|
92
|
+
return prompt
|
|
93
|
+
blocks = [f"## Навык: {s['name']}\n{s['body']}" for s in ACTIVE]
|
|
94
|
+
return prompt + "\n\nСледуй инструкциям выбранных навыков:\n\n" + "\n\n".join(blocks)
|
redsba/tools.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Инструменты агента: файлы и команды внутри рабочей папки, с подтверждением."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from redsba.config import slot_ready
|
|
12
|
+
from redsba.providers import ask
|
|
13
|
+
|
|
14
|
+
ROOT = Path.cwd().resolve()
|
|
15
|
+
# конфиг слотов, передаётся из app.py при запуске чата
|
|
16
|
+
CONFIG = {"slots": []}
|
|
17
|
+
# сколько раз мозг обратился к помощнику за один запрос пользователя
|
|
18
|
+
HELPER_CALLS = [0]
|
|
19
|
+
MAX_HELPER_CALLS = 3
|
|
20
|
+
BACKUP_DIR = ROOT / ".redsba_backup"
|
|
21
|
+
LIMIT = 8000
|
|
22
|
+
# "всегда в этой сессии" для каждого типа действия
|
|
23
|
+
APPROVED = {"write": False, "edit": False, "run": False, "fetch": False}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _TextExtractor(HTMLParser):
|
|
27
|
+
"""Достаёт видимый текст из HTML, пропуская скрипты и стили."""
|
|
28
|
+
|
|
29
|
+
SKIP = {"script", "style", "noscript", "svg", "head"}
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.parts = []
|
|
34
|
+
self._skip = 0
|
|
35
|
+
|
|
36
|
+
def handle_starttag(self, tag, attrs):
|
|
37
|
+
if tag in self.SKIP:
|
|
38
|
+
self._skip += 1
|
|
39
|
+
|
|
40
|
+
def handle_endtag(self, tag):
|
|
41
|
+
if tag in self.SKIP and self._skip:
|
|
42
|
+
self._skip -= 1
|
|
43
|
+
|
|
44
|
+
def handle_data(self, data):
|
|
45
|
+
if not self._skip and data.strip():
|
|
46
|
+
self.parts.append(data.strip())
|
|
47
|
+
|
|
48
|
+
SPECS = [
|
|
49
|
+
{
|
|
50
|
+
"name": "read_file",
|
|
51
|
+
"description": "Прочитать текстовый файл рабочей папки. Возвращает строки с номерами.",
|
|
52
|
+
"parameters": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {"path": {"type": "string", "description": "путь относительно рабочей папки"}},
|
|
55
|
+
"required": ["path"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "list_dir",
|
|
60
|
+
"description": "Показать содержимое папки рабочей папки.",
|
|
61
|
+
"parameters": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {"path": {"type": "string", "description": "путь, по умолчанию '.'"}},
|
|
64
|
+
"required": [],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "write_file",
|
|
69
|
+
"description": "Создать или полностью перезаписать файл. Пользователь подтверждает действие.",
|
|
70
|
+
"parameters": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"path": {"type": "string"},
|
|
74
|
+
"content": {"type": "string", "description": "полное содержимое файла"},
|
|
75
|
+
},
|
|
76
|
+
"required": ["path", "content"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "edit_file",
|
|
81
|
+
"description": ("Заменить точный фрагмент текста в файле. Фрагмент old должен встречаться "
|
|
82
|
+
"ровно один раз. Пользователь подтверждает действие."),
|
|
83
|
+
"parameters": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"path": {"type": "string"},
|
|
87
|
+
"old": {"type": "string", "description": "точный текст для замены"},
|
|
88
|
+
"new": {"type": "string", "description": "новый текст"},
|
|
89
|
+
},
|
|
90
|
+
"required": ["path", "old", "new"],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "fetch_url",
|
|
95
|
+
"description": ("Открыть веб-страницу по адресу http или https и получить её текст. "
|
|
96
|
+
"Пользователь подтверждает каждый адрес."),
|
|
97
|
+
"parameters": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {"url": {"type": "string", "description": "полный адрес, например https://example.com"}},
|
|
100
|
+
"required": ["url"],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "ask_helper",
|
|
105
|
+
"description": ("Спросить помощника (слот с ролью helper) и получить его ответ. "
|
|
106
|
+
f"Не больше {MAX_HELPER_CALLS} обращений за один запрос пользователя. "
|
|
107
|
+
"Используй, когда нужен второй взгляд или подсказка по сложному вопросу."),
|
|
108
|
+
"parameters": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"question": {"type": "string", "description": "вопрос помощнику, с нужным контекстом"},
|
|
112
|
+
"slot": {"type": "integer", "description": "номер слота помощника, по умолчанию первый свободный helper"},
|
|
113
|
+
},
|
|
114
|
+
"required": ["question"],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "run_command",
|
|
119
|
+
"description": ("Выполнить shell-команду в рабочей папке, таймаут 60 секунд. "
|
|
120
|
+
"Пользователь подтверждает действие."),
|
|
121
|
+
"parameters": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {"command": {"type": "string"}},
|
|
124
|
+
"required": ["command"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _trim(text):
|
|
131
|
+
if len(text) <= LIMIT:
|
|
132
|
+
return text
|
|
133
|
+
return text[:LIMIT] + f"\n... (обрезано, всего {len(text)} символов)"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _path(p):
|
|
137
|
+
target = (ROOT / p).resolve()
|
|
138
|
+
if target != ROOT and not target.is_relative_to(ROOT):
|
|
139
|
+
raise ValueError(f"путь вне рабочей папки: {p}")
|
|
140
|
+
return target
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _confirm(kind, title, details=""):
|
|
144
|
+
if APPROVED[kind]:
|
|
145
|
+
return True
|
|
146
|
+
print(f"\n{title}")
|
|
147
|
+
if details:
|
|
148
|
+
print(details)
|
|
149
|
+
ans = input("Разрешить? [y] да / [n] нет / [a] всегда в этой сессии: ").strip().lower()
|
|
150
|
+
if ans == "a":
|
|
151
|
+
APPROVED[kind] = True
|
|
152
|
+
return True
|
|
153
|
+
return ans in ("y", "yes", "д", "да")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _backup(path):
|
|
157
|
+
if not path.exists():
|
|
158
|
+
return
|
|
159
|
+
dest = BACKUP_DIR / path.relative_to(ROOT)
|
|
160
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
161
|
+
dest = dest.with_name(f"{dest.name}.{stamp}.bak")
|
|
162
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
dest.write_bytes(path.read_bytes())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _diff(old, new, name):
|
|
167
|
+
lines = difflib.unified_diff(old.splitlines(), new.splitlines(),
|
|
168
|
+
fromfile=name, tofile=name, lineterm="")
|
|
169
|
+
return "\n".join(list(lines)[:80])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read(p):
|
|
173
|
+
path = _path(p)
|
|
174
|
+
if not path.is_file():
|
|
175
|
+
return f"Не файл: {p}"
|
|
176
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
177
|
+
return _trim("\n".join(f"{i}\t{line}" for i, line in enumerate(lines, 1)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _list(p):
|
|
181
|
+
path = _path(p)
|
|
182
|
+
if not path.is_dir():
|
|
183
|
+
return f"Не папка: {p}"
|
|
184
|
+
items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
|
185
|
+
return "\n".join(f"{x.name}/" if x.is_dir() else x.name for x in items) or "(пусто)"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _write(p, content):
|
|
189
|
+
path = _path(p)
|
|
190
|
+
if path.is_dir():
|
|
191
|
+
return f"Это папка: {p}"
|
|
192
|
+
if path.exists():
|
|
193
|
+
old = path.read_text(encoding="utf-8", errors="replace")
|
|
194
|
+
details = _diff(old, content, p)
|
|
195
|
+
else:
|
|
196
|
+
details = "\n".join(content.splitlines()[:30])
|
|
197
|
+
if not _confirm("write", f"Записать файл: {p}", details):
|
|
198
|
+
return "Пользователь отклонил запись."
|
|
199
|
+
_backup(path)
|
|
200
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
path.write_text(content, encoding="utf-8")
|
|
202
|
+
return f"Записано: {p} ({len(content)} симв.)"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _edit(p, old, new):
|
|
206
|
+
path = _path(p)
|
|
207
|
+
if not path.is_file():
|
|
208
|
+
return f"Не файл: {p}"
|
|
209
|
+
if not old:
|
|
210
|
+
return "Фрагмент old пустой."
|
|
211
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
212
|
+
count = text.count(old)
|
|
213
|
+
if count == 0:
|
|
214
|
+
return "Фрагмент не найден. Сначала прочитайте файл и возьмите точный текст."
|
|
215
|
+
if count > 1:
|
|
216
|
+
return f"Фрагмент встречается {count} раз. Сделайте old уникальнее."
|
|
217
|
+
updated = text.replace(old, new, 1)
|
|
218
|
+
if not _confirm("edit", f"Правка файла: {p}", _diff(text, updated, p)):
|
|
219
|
+
return "Пользователь отклонил правку."
|
|
220
|
+
_backup(path)
|
|
221
|
+
path.write_text(updated, encoding="utf-8")
|
|
222
|
+
return f"Правка применена: {p}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _fetch(url):
|
|
226
|
+
if not url.startswith(("http://", "https://")):
|
|
227
|
+
return "Разрешены только адреса http:// и https://"
|
|
228
|
+
if not _confirm("fetch", f"Открыть адрес: {url}"):
|
|
229
|
+
return "Пользователь отклонил открытие адреса."
|
|
230
|
+
r = requests.get(url, timeout=30, headers={"User-Agent": "redsba-code/0.1"})
|
|
231
|
+
r.raise_for_status()
|
|
232
|
+
ctype = r.headers.get("Content-Type", "")
|
|
233
|
+
if "html" in ctype:
|
|
234
|
+
parser = _TextExtractor()
|
|
235
|
+
parser.feed(r.text)
|
|
236
|
+
text = "\n".join(parser.parts)
|
|
237
|
+
else:
|
|
238
|
+
text = r.text
|
|
239
|
+
return _trim(f"Адрес: {r.url}\n\n{text}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ask_helper(question, slot_no=None):
|
|
243
|
+
if HELPER_CALLS[0] >= MAX_HELPER_CALLS:
|
|
244
|
+
return f"Лимит обращений к помощнику ({MAX_HELPER_CALLS}) исчерпан. Решайте сами."
|
|
245
|
+
slots = CONFIG["slots"]
|
|
246
|
+
if slot_no is not None:
|
|
247
|
+
if not 1 <= slot_no <= len(slots):
|
|
248
|
+
return f"Нет слота с номером {slot_no}."
|
|
249
|
+
helper = slots[slot_no - 1]
|
|
250
|
+
if helper["role"] != "helper" or not slot_ready(helper):
|
|
251
|
+
return f"Слот {slot_no} не готов или не имеет роль helper."
|
|
252
|
+
else:
|
|
253
|
+
helper = next((s for s in slots if s["role"] == "helper" and slot_ready(s)), None)
|
|
254
|
+
if helper is None:
|
|
255
|
+
return "Нет готового слота с ролью helper."
|
|
256
|
+
HELPER_CALLS[0] += 1
|
|
257
|
+
answer = ask(helper, question)
|
|
258
|
+
return f"Ответ помощника ({helper['name']}, обращение {HELPER_CALLS[0]} из {MAX_HELPER_CALLS}):\n{answer}"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _run(command):
|
|
262
|
+
if not _confirm("run", f"Команда: {command}"):
|
|
263
|
+
return "Пользователь отклонил команду."
|
|
264
|
+
proc = subprocess.run(command, shell=True, cwd=ROOT, capture_output=True,
|
|
265
|
+
text=True, timeout=60)
|
|
266
|
+
return _trim(f"код выхода {proc.returncode}\n{proc.stdout}{proc.stderr}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def execute(name, args):
|
|
270
|
+
"""Выполняет инструмент и возвращает текст результата для модели."""
|
|
271
|
+
try:
|
|
272
|
+
if name == "read_file":
|
|
273
|
+
return _read(args["path"])
|
|
274
|
+
if name == "list_dir":
|
|
275
|
+
return _list(args.get("path", "."))
|
|
276
|
+
if name == "write_file":
|
|
277
|
+
return _write(args["path"], args["content"])
|
|
278
|
+
if name == "edit_file":
|
|
279
|
+
return _edit(args["path"], args["old"], args["new"])
|
|
280
|
+
if name == "run_command":
|
|
281
|
+
return _run(args["command"])
|
|
282
|
+
if name == "fetch_url":
|
|
283
|
+
return _fetch(args["url"])
|
|
284
|
+
if name == "ask_helper":
|
|
285
|
+
slot_no = args.get("slot")
|
|
286
|
+
return _ask_helper(args["question"], int(slot_no) if slot_no is not None else None)
|
|
287
|
+
return f"Неизвестный инструмент: {name}"
|
|
288
|
+
except Exception as e:
|
|
289
|
+
return f"Ошибка: {e}"
|
redsba/ui.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.application import Application
|
|
8
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
9
|
+
from prompt_toolkit.layout import Layout
|
|
10
|
+
from prompt_toolkit.layout.containers import HSplit, Window
|
|
11
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
12
|
+
from prompt_toolkit.styles import Style
|
|
13
|
+
|
|
14
|
+
STYLE = Style.from_dict({"title": "bold", "sel": "reverse", "hint": "#888888"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def clear():
|
|
18
|
+
os.system("clear")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def menu(title, items):
|
|
22
|
+
"""Выбор стрелками. Возвращает индекс или None (Esc)."""
|
|
23
|
+
index = [0]
|
|
24
|
+
kb = KeyBindings()
|
|
25
|
+
|
|
26
|
+
@kb.add("up")
|
|
27
|
+
def _(event):
|
|
28
|
+
index[0] = (index[0] - 1) % len(items)
|
|
29
|
+
|
|
30
|
+
@kb.add("down")
|
|
31
|
+
def _(event):
|
|
32
|
+
index[0] = (index[0] + 1) % len(items)
|
|
33
|
+
|
|
34
|
+
@kb.add("enter")
|
|
35
|
+
def _(event):
|
|
36
|
+
event.app.exit(result=index[0])
|
|
37
|
+
|
|
38
|
+
@kb.add("escape")
|
|
39
|
+
def _(event):
|
|
40
|
+
event.app.exit(result=None)
|
|
41
|
+
|
|
42
|
+
def render():
|
|
43
|
+
lines = [("class:title", f"{title}\n\n")]
|
|
44
|
+
for i, item in enumerate(items):
|
|
45
|
+
if i == index[0]:
|
|
46
|
+
lines.append(("class:sel", f" > {item}\n"))
|
|
47
|
+
else:
|
|
48
|
+
lines.append(("", f" {item}\n"))
|
|
49
|
+
lines.append(("class:hint", "\n стрелки - выбор, Enter - ок, Esc - назад"))
|
|
50
|
+
return lines
|
|
51
|
+
|
|
52
|
+
app = Application(
|
|
53
|
+
layout=Layout(HSplit([Window(FormattedTextControl(render), always_hide_cursor=True)])),
|
|
54
|
+
key_bindings=kb,
|
|
55
|
+
style=STYLE,
|
|
56
|
+
full_screen=False,
|
|
57
|
+
)
|
|
58
|
+
return app.run()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class spinner:
|
|
62
|
+
"""Крутилка в строке, пока идёт запрос к модели. Используется как with."""
|
|
63
|
+
|
|
64
|
+
FRAMES = "|/-\\"
|
|
65
|
+
|
|
66
|
+
def __init__(self, text="Думаю"):
|
|
67
|
+
self.text = text
|
|
68
|
+
self._stop = threading.Event()
|
|
69
|
+
self._thread = None
|
|
70
|
+
|
|
71
|
+
def _run(self):
|
|
72
|
+
for frame in itertools.cycle(self.FRAMES):
|
|
73
|
+
if self._stop.is_set():
|
|
74
|
+
break
|
|
75
|
+
sys.stdout.write(f"\r{frame} {self.text}...")
|
|
76
|
+
sys.stdout.flush()
|
|
77
|
+
time.sleep(0.12)
|
|
78
|
+
|
|
79
|
+
def __enter__(self):
|
|
80
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc):
|
|
85
|
+
self._stop.set()
|
|
86
|
+
self._thread.join()
|
|
87
|
+
sys.stdout.write("\r" + " " * (len(self.text) + 6) + "\r")
|
|
88
|
+
sys.stdout.flush()
|
|
89
|
+
return False
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redsba-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Терминальный клиент ИИ со слотами провайдеров (Groq, Gemini, OpenAI-совместимые)
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests>=2.31
|
|
8
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
9
|
+
|
|
10
|
+
# redSBA code
|
|
11
|
+
|
|
12
|
+
Терминальный клиент ИИ со слотами провайдеров.
|
|
13
|
+
|
|
14
|
+
## Установка
|
|
15
|
+
|
|
16
|
+
pip install .
|
|
17
|
+
|
|
18
|
+
## Запуск
|
|
19
|
+
|
|
20
|
+
redsba
|
|
21
|
+
|
|
22
|
+
Настройте слот в разделе `setting`: выберите шаблон провайдера, вставьте ключ и введите модель вручную.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
redsba/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
redsba/__main__.py,sha256=8brjSH7qhBjmPMm_Dhk_JneYd1KrI00L_hDg44Pxu1U,159
|
|
3
|
+
redsba/app.py,sha256=vnpeomFb6-Yzj2vkyk13ZMM8UeGCsmtz9z8ZS-IVNdQ,10794
|
|
4
|
+
redsba/chats.py,sha256=SJIg74vD14-AHTuw7OXdtM39r8TL92ziiGxugPeCGzo,1293
|
|
5
|
+
redsba/config.py,sha256=a-ZIyoLIZcL-MZ_BVDZ1jFFT7dl-lbnguhQFyrw1iN4,1288
|
|
6
|
+
redsba/providers.py,sha256=69yUiqysUQ8k6RAbIboLaWLNxS7LIRXvWMUyA8_iDHI,7294
|
|
7
|
+
redsba/skills.py,sha256=owrCzbmDTrqWFqaH4vIjGC2crUrOmL4DmJkQ3-yz6pg,3500
|
|
8
|
+
redsba/tools.py,sha256=-B0qf8zLGE91-OqdoTapIVLqRxSzFYmsDL4sMhNNlhY,11657
|
|
9
|
+
redsba/ui.py,sha256=4z6jCtWeP5Lxta9mPF9UBpzK4J_WvBiTQqR9L6hEVMM,2493
|
|
10
|
+
redsba_code-0.1.0.dist-info/METADATA,sha256=_IcMa5eGyrQ5v2EbkfxVK-uSkzJARaqG5kzD_WkcgAE,681
|
|
11
|
+
redsba_code-0.1.0.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
|
|
12
|
+
redsba_code-0.1.0.dist-info/entry_points.txt,sha256=TpUyO0wHk1opREZVXsg0S7QB4TDdCzQgEWu8uq5lwug,48
|
|
13
|
+
redsba_code-0.1.0.dist-info/top_level.txt,sha256=9LMtQMgZRtQnzZuPXmCk0_y05k1hxSTKYHjAz7nurA4,7
|
|
14
|
+
redsba_code-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
redsba
|