From 2b8bc6ed8b0f572c97356b39a9ade4c588f480ba Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 7 Apr 2026 16:49:33 +0800 Subject: [PATCH] Expand server tools and bot controls --- README.md | 13 +++- bot/app.py | 74 +++++++++++++++++--- serv/.env.example | 3 +- serv/app.py | 13 +++- serv/config.py | 9 ++- serv/llm.py | 22 ++++-- serv/sessions.py | 22 +++++- serv/tools.py | 170 +++++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 297 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 47f8278..e6f849d 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,10 @@ Qwen OAuth + OpenAI-compatible endpoint - хранение токенов в `~/.qwen/oauth_creds.json` - HTTP API сервера - агентный цикл с tool calling -- базовые инструменты: `list_files`, `read_file`, `write_file`, `exec_command` +- инструменты: `list_files`, `glob_search`, `grep_text`, `stat_path`, `read_file`, `write_file`, `make_directory`, `exec_command` - Telegram polling без внешних библиотек - JSON-хранилище сессий +- API списка и просмотра сессий ## Ограничения текущей реализации @@ -78,3 +79,13 @@ python3 bot/app.py curl -X POST http://127.0.0.1:8080/api/v1/auth/device/start ``` +## API + +- `GET /health` +- `GET /api/v1/auth/status` +- `POST /api/v1/auth/device/start` +- `POST /api/v1/auth/device/poll` +- `GET /api/v1/sessions` +- `POST /api/v1/session/get` +- `POST /api/v1/session/clear` +- `POST /api/v1/chat` diff --git a/bot/app.py b/bot/app.py index 4ab9705..3aebd45 100644 --- a/bot/app.py +++ b/bot/app.py @@ -15,7 +15,7 @@ STATE_FILE = Path(__file__).resolve().parent.parent / ".new-qwen" / "telegram-st def load_state() -> dict[str, Any]: if not STATE_FILE.exists(): - return {"offset": None, "sessions": {}} + return {"offset": None, "sessions": {}, "auth_flows": {}} return json.loads(STATE_FILE.read_text(encoding="utf-8")) @@ -41,11 +41,19 @@ def get_json(url: str) -> dict[str, Any]: return json.loads(response.read().decode("utf-8")) -def ensure_auth(api: TelegramAPI, config: BotConfig, chat_id: int) -> bool: +def send_text_chunks(api: TelegramAPI, chat_id: int, text: str) -> None: + normalized = text or "Пустой ответ." + chunk_size = 3800 + for start in range(0, len(normalized), chunk_size): + api.send_message(chat_id, normalized[start : start + chunk_size]) + + +def ensure_auth(api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int) -> bool: status = get_json(f"{config.server_url}/api/v1/auth/status") if status.get("authenticated"): return True started = post_json(f"{config.server_url}/api/v1/auth/device/start", {}) + state.setdefault("auth_flows", {})[str(chat_id)] = started["flow_id"] api.send_message( chat_id, "Qwen OAuth не настроен.\n" @@ -65,13 +73,30 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m session_key = f"{chat_id}:{user_id}" session_id = state.setdefault("sessions", {}).get(session_key) + state.setdefault("auth_flows", {}) if text == "/start": - api.send_message(chat_id, "new-qwen bot готов. Команды: /auth, /clear.") + api.send_message( + chat_id, + "new-qwen bot готов.\nКоманды: /help, /auth, /status, /session, /clear.", + ) + return + + if text == "/help": + api.send_message( + chat_id, + "Команды:\n" + "/auth - начать Qwen OAuth\n" + "/auth_check [flow_id] - проверить авторизацию\n" + "/status - статус OAuth и сервера\n" + "/session - показать текущую сессию\n" + "/clear - очистить контекст", + ) return if text == "/auth": started = post_json(f"{config.server_url}/api/v1/auth/device/start", {}) + state["auth_flows"][str(chat_id)] = started["flow_id"] api.send_message( chat_id, "Откройте ссылку для авторизации Qwen OAuth:\n" @@ -82,19 +107,52 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m if text.startswith("/auth_check"): parts = text.split(maxsplit=1) - if len(parts) != 2: - api.send_message(chat_id, "Использование: /auth_check ") + flow_id = parts[1] if len(parts) == 2 else state["auth_flows"].get(str(chat_id)) + if not flow_id: + api.send_message(chat_id, "Нет активного flow_id. Сначала вызови /auth.") return result = post_json( f"{config.server_url}/api/v1/auth/device/poll", - {"flow_id": parts[1]}, + {"flow_id": flow_id}, ) if result.get("done"): + state["auth_flows"].pop(str(chat_id), None) api.send_message(chat_id, "Qwen OAuth успешно настроен.") else: api.send_message(chat_id, "Авторизация ещё не завершена. Повторите команду через пару секунд.") return + if text == "/status": + status = get_json(f"{config.server_url}/api/v1/auth/status") + send_text_chunks( + api, + chat_id, + "Сервер доступен.\n" + f"OAuth: {'configured' if status.get('authenticated') else 'not configured'}\n" + f"resource_url: {status.get('resource_url')}\n" + f"expires_at: {status.get('expires_at')}", + ) + return + + if text == "/session": + if not session_id: + api.send_message(chat_id, "У этого чата ещё нет активной сессии.") + return + session = post_json( + f"{config.server_url}/api/v1/session/get", + {"session_id": session_id}, + ) + send_text_chunks( + api, + chat_id, + "session_id: {session_id}\nmessages: {count}\nlast_answer: {last_answer}".format( + session_id=session_id, + count=len(session.get("messages", [])), + last_answer=session.get("last_answer") or "-", + ), + ) + return + if text == "/clear": if session_id: post_json(f"{config.server_url}/api/v1/session/clear", {"session_id": session_id}) @@ -102,7 +160,7 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m api.send_message(chat_id, "Контекст сессии очищен.") return - if not ensure_auth(api, config, chat_id): + if not ensure_auth(api, config, state, chat_id): return api.send_message(chat_id, "Обрабатываю запрос...") @@ -116,7 +174,7 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m ) state["sessions"][session_key] = result["session_id"] answer = result.get("answer") or "Пустой ответ от модели." - api.send_message(chat_id, answer[:4000]) + send_text_chunks(api, chat_id, answer) def main() -> None: diff --git a/serv/.env.example b/serv/.env.example index 42f1eb6..a8318f7 100644 --- a/serv/.env.example +++ b/serv/.env.example @@ -5,4 +5,5 @@ NEW_QWEN_WORKSPACE_ROOT=/home/mirivlad/git NEW_QWEN_SESSION_DIR=/home/mirivlad/git/new-qwen/.new-qwen/sessions NEW_QWEN_SYSTEM_PROMPT= NEW_QWEN_MAX_TOOL_ROUNDS=8 - +NEW_QWEN_MAX_FILE_READ_BYTES=200000 +NEW_QWEN_MAX_COMMAND_OUTPUT_BYTES=12000 diff --git a/serv/app.py b/serv/app.py index aded473..7a1db35 100644 --- a/serv/app.py +++ b/serv/app.py @@ -20,7 +20,7 @@ class AppState: self.config = config self.oauth = QwenOAuthManager() self.sessions = SessionStore(config.session_dir) - self.tools = ToolRegistry(config.workspace_root) + self.tools = ToolRegistry(config) self.agent = QwenAgent(config, self.oauth, self.tools) self.pending_device_flows: dict[str, DeviceAuthState] = {} self.lock = threading.Lock() @@ -65,6 +65,9 @@ class RequestHandler(BaseHTTPRequestHandler): if self.path == "/api/v1/auth/status": self._send(HTTPStatus.OK, self.app.auth_status()) return + if self.path == "/api/v1/sessions": + self._send(HTTPStatus.OK, {"sessions": self.app.sessions.list_sessions()}) + return self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"}) def do_POST(self) -> None: @@ -120,6 +123,7 @@ class RequestHandler(BaseHTTPRequestHandler): "user_id": user_id, "updated_at": int(time.time()), "messages": persisted_messages, + "last_answer": result["answer"], }, ) self._send( @@ -133,6 +137,12 @@ class RequestHandler(BaseHTTPRequestHandler): ) return + if self.path == "/api/v1/session/get": + body = self._json_body() + session_id = body["session_id"] + self._send(HTTPStatus.OK, self.app.sessions.load(session_id)) + return + if self.path == "/api/v1/session/clear": body = self._json_body() session_id = body["session_id"] @@ -163,4 +173,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/serv/config.py b/serv/config.py index b3f08cd..a8127d6 100644 --- a/serv/config.py +++ b/serv/config.py @@ -25,6 +25,8 @@ class ServerConfig: session_dir: Path system_prompt: str max_tool_rounds: int + max_file_read_bytes: int + max_command_output_bytes: int @classmethod def load(cls) -> "ServerConfig": @@ -47,5 +49,10 @@ class ServerConfig: session_dir=session_dir.resolve(), system_prompt=os.environ.get("NEW_QWEN_SYSTEM_PROMPT", "").strip(), max_tool_rounds=int(os.environ.get("NEW_QWEN_MAX_TOOL_ROUNDS", "8")), + max_file_read_bytes=int( + os.environ.get("NEW_QWEN_MAX_FILE_READ_BYTES", "200000") + ), + max_command_output_bytes=int( + os.environ.get("NEW_QWEN_MAX_COMMAND_OUTPUT_BYTES", "12000") + ), ) - diff --git a/serv/llm.py b/serv/llm.py index 54219f5..5bd00f0 100644 --- a/serv/llm.py +++ b/serv/llm.py @@ -2,7 +2,7 @@ from __future__ import annotations import json from typing import Any -from urllib import request +from urllib import error, request from config import ServerConfig from oauth import OAuthError, QwenOAuthManager @@ -42,8 +42,12 @@ class QwenAgent: }, method="POST", ) - with request.urlopen(req, timeout=180) as response: - return json.loads(response.read().decode("utf-8")) + try: + with request.urlopen(req, timeout=180) as response: + return json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise OAuthError(f"LLM request failed with HTTP {exc.code}: {body}") from exc def run(self, history: list[dict[str, Any]], user_message: str) -> dict[str, Any]: system_prompt = self.config.system_prompt or DEFAULT_SYSTEM_PROMPT @@ -100,5 +104,13 @@ class QwenAgent: } ) - raise OAuthError("Max tool rounds exceeded") - + final_message = ( + "Остановлено по лимиту tool rounds. Попробуй сузить задачу или продолжить отдельным сообщением." + ) + events.append({"type": "assistant", "content": final_message}) + return { + "answer": final_message, + "events": events, + "usage": None, + "messages": messages + [{"role": "assistant", "content": final_message}], + } diff --git a/serv/sessions.py b/serv/sessions.py index e758ed6..5f8a4c8 100644 --- a/serv/sessions.py +++ b/serv/sessions.py @@ -10,7 +10,7 @@ class SessionStore: def __init__(self, base_dir: Path) -> None: self.base_dir = base_dir self.base_dir.mkdir(parents=True, exist_ok=True) - self._lock = threading.Lock() + self._lock = threading.RLock() def _path(self, session_id: str) -> Path: return self.base_dir / f"{session_id}.json" @@ -35,9 +35,27 @@ class SessionStore: self.save(session_id, payload) return payload + def list_sessions(self) -> list[dict[str, Any]]: + sessions: list[dict[str, Any]] = [] + for path in sorted(self.base_dir.glob("*.json")): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + sessions.append( + { + "session_id": payload.get("session_id", path.stem), + "user_id": payload.get("user_id"), + "updated_at": payload.get("updated_at"), + "message_count": len(payload.get("messages", [])), + "last_answer": payload.get("last_answer"), + } + ) + sessions.sort(key=lambda item: item.get("updated_at") or 0, reverse=True) + return sessions + def clear(self, session_id: str) -> None: with self._lock: path = self._path(session_id) if path.exists(): path.unlink() - diff --git a/serv/tools.py b/serv/tools.py index 039f453..ea65a34 100644 --- a/serv/tools.py +++ b/serv/tools.py @@ -1,22 +1,32 @@ from __future__ import annotations +import fnmatch import json +import os +import re import subprocess from pathlib import Path from typing import Any, Callable +from config import ServerConfig + class ToolError(RuntimeError): pass class ToolRegistry: - def __init__(self, workspace_root: Path) -> None: - self.workspace_root = workspace_root.resolve() + def __init__(self, config: ServerConfig) -> None: + self.config = config + self.workspace_root = config.workspace_root.resolve() self._handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "list_files": self.list_files, + "glob_search": self.glob_search, + "grep_text": self.grep_text, + "stat_path": self.stat_path, "read_file": self.read_file, "write_file": self.write_file, + "make_directory": self.make_directory, "exec_command": self.exec_command, } @@ -36,6 +46,52 @@ class ToolRegistry: }, }, }, + { + "type": "function", + "function": { + "name": "glob_search", + "description": "Find workspace paths matching a glob pattern.", + "parameters": { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "base_path": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["pattern"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "grep_text", + "description": "Search text in workspace files using a regular expression.", + "parameters": { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "base_path": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["pattern"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "stat_path", + "description": "Return metadata for a workspace path.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + "required": ["path"], + }, + }, + }, { "type": "function", "function": { @@ -65,6 +121,20 @@ class ToolRegistry: }, }, }, + { + "type": "function", + "function": { + "name": "make_directory", + "description": "Create a directory inside the workspace.", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + "required": ["path"], + }, + }, + }, { "type": "function", "function": { @@ -108,25 +178,108 @@ class ToolRegistry: items.append( { "name": item.name, - "path": str(item.relative_to(self.workspace_root)), + "path": str(item.relative_to(self.workspace_root).as_posix()), "type": "dir" if item.is_dir() else "file", } ) return {"items": items} + def glob_search(self, arguments: dict[str, Any]) -> dict[str, Any]: + pattern = arguments["pattern"] + base = self._resolve(arguments.get("base_path", ".")) + if not base.is_dir(): + raise ToolError("base_path is not a directory") + limit = max(1, min(int(arguments.get("limit", 200)), 1000)) + matches: list[str] = [] + for root, dirs, files in os.walk(base): + dirs.sort() + files.sort() + rel_root = Path(root).relative_to(self.workspace_root) + for name in dirs + files: + rel_path = (rel_root / name).as_posix() + if fnmatch.fnmatch(rel_path, pattern): + matches.append(rel_path) + if len(matches) >= limit: + return {"matches": matches, "truncated": True} + return {"matches": matches, "truncated": False} + + def grep_text(self, arguments: dict[str, Any]) -> dict[str, Any]: + regex = re.compile(arguments["pattern"]) + base = self._resolve(arguments.get("base_path", ".")) + if not base.is_dir(): + raise ToolError("base_path is not a directory") + limit = max(1, min(int(arguments.get("limit", 100)), 500)) + matches: list[dict[str, Any]] = [] + for root, dirs, files in os.walk(base): + dirs.sort() + files.sort() + for file_name in files: + file_path = Path(root) / file_name + try: + text = file_path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + for lineno, line in enumerate(text.splitlines(), start=1): + if regex.search(line): + matches.append( + { + "path": file_path.relative_to(self.workspace_root).as_posix(), + "line": lineno, + "text": line[:500], + } + ) + if len(matches) >= limit: + return {"matches": matches, "truncated": True} + return {"matches": matches, "truncated": False} + + def stat_path(self, arguments: dict[str, Any]) -> dict[str, Any]: + target = self._resolve(arguments["path"]) + rel_path = target.relative_to(self.workspace_root).as_posix() + if not target.exists(): + return {"exists": False, "path": rel_path} + stat = target.stat() + return { + "exists": True, + "path": rel_path, + "type": "dir" if target.is_dir() else "file", + "size": stat.st_size, + "mtime": int(stat.st_mtime), + } + def read_file(self, arguments: dict[str, Any]) -> dict[str, Any]: target = self._resolve(arguments["path"]) if not target.exists(): raise ToolError("File does not exist") if not target.is_file(): raise ToolError("Path is not a file") - return {"path": str(target.relative_to(self.workspace_root)), "content": target.read_text(encoding="utf-8")} + content = target.read_text(encoding="utf-8") + encoded = content.encode("utf-8") + truncated = False + if len(encoded) > self.config.max_file_read_bytes: + content = encoded[: self.config.max_file_read_bytes].decode( + "utf-8", + errors="ignore", + ) + truncated = True + return { + "path": target.relative_to(self.workspace_root).as_posix(), + "content": content, + "truncated": truncated, + } def write_file(self, arguments: dict[str, Any]) -> dict[str, Any]: target = self._resolve(arguments["path"]) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(arguments["content"], encoding="utf-8") - return {"path": str(target.relative_to(self.workspace_root)), "bytes_written": len(arguments["content"].encode("utf-8"))} + return { + "path": target.relative_to(self.workspace_root).as_posix(), + "bytes_written": len(arguments["content"].encode("utf-8")), + } + + def make_directory(self, arguments: dict[str, Any]) -> dict[str, Any]: + target = self._resolve(arguments["path"]) + target.mkdir(parents=True, exist_ok=True) + return {"path": target.relative_to(self.workspace_root).as_posix(), "created": True} def exec_command(self, arguments: dict[str, Any]) -> dict[str, Any]: cwd = self._resolve(arguments.get("cwd", ".")) @@ -141,13 +294,12 @@ class ToolRegistry: ) return { "command": command, - "cwd": str(cwd.relative_to(self.workspace_root)), + "cwd": cwd.relative_to(self.workspace_root).as_posix(), "returncode": completed.returncode, - "stdout": completed.stdout[-12000:], - "stderr": completed.stderr[-12000:], + "stdout": completed.stdout[-self.config.max_command_output_bytes :], + "stderr": completed.stderr[-self.config.max_command_output_bytes :], } @staticmethod def encode_result(result: dict[str, Any]) -> str: return json.dumps(result, ensure_ascii=False) -