from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parent DEPS_DIR = ROOT / ".deps" if DEPS_DIR.exists(): sys.path.insert(0, str(DEPS_DIR)) import asyncio import hashlib import json import logging import os import queue import re import secrets import shutil import tempfile import threading import uuid from datetime import datetime, timezone from threading import Lock from typing import Any, AsyncIterator, Mapping from urllib.parse import quote from dotenv import load_dotenv from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, Response, UploadFile from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel import log_preanalysis load_dotenv(ROOT / ".env") DATA_DIR = ROOT / "data" UPLOAD_DIR = DATA_DIR / "uploads" AGENT_DIR = DATA_DIR / "agent_workspaces" DB_FILE = DATA_DIR / "db.json" STATIC_DIR = ROOT / "static" db_lock = Lock() logger = logging.getLogger("helpomatica") AGENT_TIMEOUT_SECONDS = 180 SESSION_COOKIE = "helpomatica_session" SESSION_DAYS = 30 PBKDF2_ITERATIONS = 200_000 # Parallel local agents: different chat_ids may run bridges concurrently. # Per-chat still single-flight — one active run + at most one waiting request. # (Optional future: CURSOR_USE_CLOUD=1 → Cloud Agents API for remote parallel runs.) agent_status_lock = Lock() active_runs: dict[str, dict[str, Any]] = {} chat_queue_lock = Lock() # chat_id -> {"running": bool, "waiting": int, "turn": threading.Event} chat_queues: dict[str, dict[str, Any]] = {} class CursorStartupError(RuntimeError): pass def format_agent_error(error: Exception) -> str: message = str(error).strip() if message: return message return f"{error.__class__.__name__}: проверьте CURSOR_API_KEY и запуск через .\\run.ps1" def now() -> str: return datetime.now(timezone.utc).isoformat() def empty_db() -> dict: return { "users": [], "sessions": [], "projects": [], "chats": [], "messages": [], "files": [], "analysis_cache": [], } def hash_password(password: str, salt: str | None = None) -> str: salt = salt or secrets.token_hex(16) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt.encode("utf-8"), PBKDF2_ITERATIONS, ) return f"{salt}${digest.hex()}" def verify_password(password: str, stored: str) -> bool: try: salt, digest = stored.split("$", 1) except ValueError: return False candidate = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt.encode("utf-8"), PBKDF2_ITERATIONS, ).hex() return secrets.compare_digest(candidate, digest) def public_user(user: dict) -> dict: return { "id": user["id"], "username": user["username"], "display_name": user["display_name"], "role": user["role"], "created_at": user["created_at"], } def default_model() -> str: return os.getenv("CURSOR_MODEL", "auto").strip() or "auto" def migrate_db(data: dict) -> dict: changed = False for key in ("users", "sessions", "projects", "chats", "messages", "files", "analysis_cache"): if key not in data or not isinstance(data[key], list): data[key] = [] changed = True if not data["users"]: username = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin" password = os.getenv("ADMIN_PASSWORD", "admin") admin = { "id": uuid.uuid4().hex, "username": username, "display_name": "Admin", "password_hash": hash_password(password), "role": "admin", "created_at": now(), } data["users"].append(admin) changed = True logger.info("Bootstrap admin user created: %s", username) else: admin = next((u for u in data["users"] if u.get("role") == "admin"), data["users"][0]) admin_id = admin["id"] admin_name = admin.get("display_name") or admin.get("username") or "Admin" for project in data["projects"]: if "model" not in project: project["model"] = default_model() changed = True if "created_by" not in project: project["created_by"] = admin_id changed = True for chat in data["chats"]: if "created_by" not in chat: chat["created_by"] = admin_id changed = True if "updated_by" not in chat: chat["updated_by"] = admin_id changed = True chats_missing_order = [chat for chat in data["chats"] if "sort_order" not in chat] if chats_missing_order: by_project: dict[str, list[dict]] = {} for chat in data["chats"]: by_project.setdefault(chat["project_id"], []).append(chat) for project_chats in by_project.values(): ordered = sorted( project_chats, key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True, ) for index, chat in enumerate(ordered): if chat.get("sort_order") != index: chat["sort_order"] = index changed = True for message in data["messages"]: if "user_id" not in message: if message.get("role") == "user": message["user_id"] = admin_id message["author_name"] = admin_name else: message["user_id"] = None message["author_name"] = "Помощник" changed = True elif "author_name" not in message: if message.get("role") == "assistant": message["author_name"] = "Помощник" else: message["author_name"] = admin_name changed = True for file_entry in data["files"]: chat_id = file_entry.get("chat_id") or None if chat_id == "": chat_id = None scope = str(file_entry.get("scope") or "").strip().lower() if scope not in ("project", "chat"): # Legacy uploads had no chat_id: keep them global within the project. scope = "chat" if chat_id else "project" if scope == "chat" and not chat_id: scope = "project" if scope == "project": chat_id = None if file_entry.get("scope") != scope: file_entry["scope"] = scope changed = True if "chat_id" not in file_entry or file_entry.get("chat_id") != chat_id: file_entry["chat_id"] = chat_id changed = True if changed: save_db(data) return data def load_db() -> dict: DATA_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(exist_ok=True) if not DB_FILE.exists(): save_db(empty_db()) with db_lock: data = json.loads(DB_FILE.read_text(encoding="utf-8")) return migrate_db(data) def save_db(data: dict) -> None: DATA_DIR.mkdir(exist_ok=True) with db_lock: temp_file = DB_FILE.with_suffix(".tmp") temp_file.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) temp_file.replace(DB_FILE) def find(items: list[dict], item_id: str, label: str) -> dict: item = next((entry for entry in items if entry["id"] == item_id), None) if not item: raise HTTPException(status_code=404, detail=f"{label} не найден") return item def resolve_session(session_id: str | None) -> dict | None: if not session_id: return None data = load_db() session = next((s for s in data["sessions"] if s["id"] == session_id), None) if not session: return None user = next((u for u in data["users"] if u["id"] == session["user_id"]), None) return user def get_current_user(request: Request) -> dict: user = getattr(request.state, "user", None) if not user: raise HTTPException(status_code=401, detail="Требуется вход") return user def require_admin(user: dict = Depends(get_current_user)) -> dict: if user.get("role") != "admin": raise HTTPException(status_code=403, detail="Только для администратора") return user # Cap workspace copies so a huge upload library cannot fill the temp disk. MAX_WORKSPACE_FILE_BYTES = 40 * 1024 * 1024 MAX_WORKSPACE_TOTAL_BYTES = 80 * 1024 * 1024 _INVALID_WIN_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _RESERVED_WIN_NAMES = { "con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10)), } def sanitize_workspace_filename(name: str) -> str: """Keep the original basename, including rotated logs like ``*.log.1``.""" base = Path(name or "file").name.strip().replace("\x00", "") base = _INVALID_WIN_NAME.sub("_", base).strip(" .") if not base: base = "file" first, dot, rest = base.partition(".") if first.lower() in _RESERVED_WIN_NAMES: base = f"_{first}{dot}{rest}" if len(base) > 180: base = base[:180] return base def unique_workspace_name(desired: str, used: set[str]) -> str: if desired.lower() not in used: used.add(desired.lower()) return desired stem = Path(desired).stem suffix = Path(desired).suffix index = 2 while True: candidate = f"{stem}_{index}{suffix}" if candidate.lower() not in used: used.add(candidate.lower()) return candidate index += 1 def rank_files_for_workspace( files: list[dict], query: str, attachment_ids: list[str] | None = None, ) -> list[dict]: """Prefer message attachments, then files named in the query, then recent.""" lowered = query.lower() keywords = {word for word in re.findall(r"[\w\u0400-\u04FF.+-]+", lowered) if len(word) > 2} attach_set = {fid for fid in (attachment_ids or []) if fid} def sort_key(entry: dict) -> tuple: attached = 1 if entry.get("id") in attach_set else 0 name = entry.get("name", "").lower() mentioned = 1 if any(token in name for token in keywords) else 0 created = entry.get("created_at") or "" return (attached, mentioned, created) return sorted(files, key=sort_key, reverse=True) def _windows_long_path(path_text: str) -> str: """Expand Win32 8.3 short names (INWORK~1) to the real long path.""" try: import ctypes from ctypes import wintypes get_long = ctypes.windll.kernel32.GetLongPathNameW get_long.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] get_long.restype = wintypes.DWORD buf = ctypes.create_unicode_buffer(32768) n = get_long(path_text, buf, 32768) if n and n < 32768 and buf.value: return buf.value except Exception: # noqa: BLE001 pass return path_text def agent_workspace_cwd(workspace: Path | str) -> str: """Absolute long-path cwd for Cursor bridge/agent (avoid Win32 8.3 short paths).""" try: resolved = Path(workspace).resolve() except OSError: resolved = Path(os.path.abspath(str(workspace))) text = str(resolved) if sys.platform == "win32": text = _windows_long_path(text) return text def file_scope_of(entry: dict) -> str: scope = str(entry.get("scope") or "").strip().lower() if scope in ("project", "chat"): return scope return "chat" if entry.get("chat_id") else "project" def iter_scoped_files( data: dict, project_id: str, chat_id: str | None = None, ) -> list[dict]: """Project-scoped files plus this chat's files. Unique by file id.""" visible: list[dict] = [] seen_ids: set[str] = set() for entry in data.get("files", []): if entry.get("project_id") != project_id: continue scope = file_scope_of(entry) if scope == "project": include = True elif scope == "chat" and chat_id and entry.get("chat_id") == chat_id: include = True else: include = False if not include: continue fid = entry.get("id") or "" if fid: if fid in seen_ids: continue seen_ids.add(fid) visible.append(entry) return visible _ATTACHED_NAMES_RE = re.compile( r"\[Прикреплённые файлы проекта:\s*([^\]]+)\]", re.IGNORECASE, ) def message_attachment_ids(messages: list[dict]) -> list[str]: ids: list[str] = [] seen: set[str] = set() for message in reversed(messages): if message.get("role") != "user": continue for ref in message.get("attachments") or []: fid = str((ref or {}).get("id") or "").strip() if fid and fid not in seen: seen.add(fid) ids.append(fid) return ids def message_attachment_names(messages: list[dict]) -> list[str]: names: list[str] = [] seen: set[str] = set() for message in reversed(messages or []): if message.get("role") != "user": continue for ref in message.get("attachments") or []: name = str((ref or {}).get("name") or "").strip() key = name.lower() if name and key not in seen: seen.add(key) names.append(name) content = str(message.get("content") or "") match = _ATTACHED_NAMES_RE.search(content) if not match: continue for raw in match.group(1).split(","): name = raw.strip() key = name.lower() if name and key not in seen: seen.add(key) names.append(name) return names def resolve_message_attachments( data: dict, project_id: str, chat_id: str | None, messages: list[dict] | None = None, attachment_ids: list[str] | None = None, ) -> list[dict]: """Resolve attached files by id, then by original basename (any scope).""" files = [ entry for entry in data.get("files", []) if entry.get("project_id") == project_id ] by_id = {str(entry.get("id") or ""): entry for entry in files if entry.get("id")} found: list[dict] = [] seen: set[str] = set() def add(entry: dict | None) -> None: if not entry: return fid = str(entry.get("id") or "") key = fid or str(entry.get("name") or "").lower() if not key or key in seen: return seen.add(key) found.append(entry) for fid in attachment_ids or []: add(by_id.get(str(fid))) for fid in message_attachment_ids(messages or []): add(by_id.get(str(fid))) for name in message_attachment_names(messages or []): key = name.lower() match = next( ( entry for entry in files if str(entry.get("name") or "").lower() == key and ( file_scope_of(entry) == "project" or not chat_id or entry.get("chat_id") == chat_id ) ), None, ) add(match) return found def dedupe_workspace_files(files: list[dict]) -> list[dict]: """Keep first occurrence per file id and per original basename.""" seen_ids: set[str] = set() seen_names: set[str] = set() unique: list[dict] = [] for entry in files: fid = entry.get("id") or "" if fid and fid in seen_ids: continue aliases = _workspace_name_aliases(entry) key = (aliases[0] if aliases else "file").lower() if key in seen_names: continue if fid: seen_ids.add(fid) seen_names.add(key) unique.append(entry) return unique _UUID_PREFIX = re.compile(r"^[0-9a-f]{32}_", re.IGNORECASE) def _strip_upload_uuid_prefixes(name: str) -> str: """Peel ``{32hex}_`` prefixes from upload storage names.""" current = Path(name or "file").name while True: stripped = _UUID_PREFIX.sub("", current, count=1) if stripped == current: return current current = stripped def _workspace_name_aliases(entry: dict) -> list[str]: """Prefer original/UI basename; if metadata is hashed-only, also expose bare name.""" raw_name = str(entry.get("name") or entry.get("stored_name") or "file") preferred = sanitize_workspace_filename(_strip_upload_uuid_prefixes(raw_name)) display = sanitize_workspace_filename(raw_name) ordered = [preferred] # When UI/db name is still ``{uuid}_file.gz``, keep that name as a secondary alias. if display.lower() != preferred.lower(): ordered.append(display) return ordered or ["file"] def _link_or_copy(source: Path, dest: Path) -> None: if dest.exists(): return try: os.link(source, dest) except OSError: shutil.copy2(source, dest) def prepare_agent_workspace( project: dict, data: dict | None = None, query: str = "", chat_id: str | None = None, attachment_ids: list[str] | None = None, messages: list[dict] | None = None, ) -> Path: """Create an isolated cwd and copy scoped uploads into it. The Cursor agent runs on the Helpomatica server machine. Client-PC paths are never available; only files from data/uploads/ for this project that are in scope (project-wide, or this chat) are placed under ./ and ./files/ so tools can open them by relative name. Message attachments and chat-scoped files are always copied (no 40/80MB cap, no extension filter). Project-wide extras still use the size cap. Always return a resolved absolute long path so bridge/shell cwd is not an 8.3 short path (e.g. ``C:\\Users\\INWORK~1\\...``), which confuses agents into leaving the temp workspace. """ isolated_root = Path(agent_workspace_cwd(Path(tempfile.gettempdir()) / "helpomatica-agent")) isolated_root.mkdir(parents=True, exist_ok=True) # mkdtemp may return a short 8.3 path on Windows — resolve to long path. workspace = Path( agent_workspace_cwd( tempfile.mkdtemp(prefix=f"hlp-{project['id'][:8]}-", dir=isolated_root) ) ) files_dir = workspace / "files" files_dir.mkdir(exist_ok=True) if data is None: _write_workspace_hints(workspace, [], []) return workspace attachments = resolve_message_attachments( data, project["id"], chat_id, messages=messages, attachment_ids=attachment_ids ) attach_ids = {str(entry.get("id") or "") for entry in attachments if entry.get("id")} attach_ids.update(str(fid) for fid in (attachment_ids or []) if fid) scoped = iter_scoped_files(data, project["id"], chat_id) others = [ entry for entry in rank_files_for_workspace(scoped, query, list(attach_ids)) if str(entry.get("id") or "") not in attach_ids ] # Attachments first (must land), then remaining chat-scoped, then project. chat_rest = [entry for entry in others if file_scope_of(entry) == "chat"] project_rest = [entry for entry in others if file_scope_of(entry) != "chat"] ordered = dedupe_workspace_files(attachments + chat_rest + project_rest) used_names: set[str] = { "readme.md", "project_files.md", "agents.md", "workspace_root.txt", ".cursorignore", "preanalysis.md", } copied_ids: set[str] = set() listing: list[str] = [] notes: list[str] = [] total_bytes = 0 def place_copy(source: Path | None, primary: str, aliases: list[str]) -> None: dest_nested = files_dir / primary dest_flat = workspace / primary if source is None: stub = ( f"# MISSING UPLOAD\n" f"Recorded as `{primary}` but the bytes are not on disk. " "Ask the user to re-upload this file.\n" ) dest_nested.write_text(stub, encoding="utf-8") else: _link_or_copy(source, dest_nested) _link_or_copy(dest_nested, dest_flat) for alias in aliases[1:]: alias_name = unique_workspace_name(alias, used_names) _link_or_copy(dest_nested, files_dir / alias_name) _link_or_copy(dest_nested, workspace / alias_name) listing.append(f"- ./files/{alias_name} (also ./{alias_name})") for entry in ordered: fid = str(entry.get("id") or "") if fid and fid in copied_ids: continue is_required = ( fid in attach_ids or file_scope_of(entry) == "chat" or log_preanalysis.is_log_filename(str(entry.get("name") or "")) ) name_aliases = _workspace_name_aliases(entry) primary = unique_workspace_name(name_aliases[0], used_names) source = UPLOAD_DIR / str(entry.get("stored_name") or "") if not source.exists() or not source.is_file(): logger.warning( "workspace file missing on disk: name=%s stored=%s chat=%s", entry.get("name"), entry.get("stored_name"), chat_id, ) notes.append( f"- ./files/{primary} (also ./{primary}) — MISSING on disk " f"(stored_name={entry.get('stored_name')}). Re-upload this file." ) try: place_copy(None, primary, name_aliases) except OSError as error: logger.warning("workspace stub failed for %s: %s", primary, error) used_names.discard(primary.lower()) continue listing.append( f"- ./files/{primary} (also ./{primary}) — MISSING on disk, stub only" ) if fid: copied_ids.add(fid) continue try: size = source.stat().st_size except OSError as error: logger.warning("workspace stat failed for %s: %s", entry.get("name"), error) notes.append( f"- ./files/{primary} — could not read size ({error})" ) continue if not is_required: if size > MAX_WORKSPACE_FILE_BYTES: logger.warning( "skip workspace copy (too large): %s (%s bytes)", entry.get("name"), size, ) notes.append( f"- ./files/{primary} — SKIPPED, too large ({size} bytes)" ) continue if total_bytes + size > MAX_WORKSPACE_TOTAL_BYTES: logger.warning( "skip workspace copy (total cap): %s", entry.get("name"), ) notes.append( f"- ./files/{primary} — SKIPPED, workspace total size cap" ) continue try: place_copy(source, primary, name_aliases) except OSError as error: logger.warning("workspace copy failed for %s: %s", entry.get("name"), error) used_names.discard(primary.lower()) notes.append(f"- ./files/{primary} — COPY FAILED ({error})") continue if fid: copied_ids.add(fid) total_bytes += size listing.append(f"- ./files/{primary} (also ./{primary})") logger.info( "workspace copy %s (%s bytes) required=%s chat=%s", primary, size, is_required, chat_id, ) _write_workspace_hints(workspace, listing, notes) return workspace def _write_workspace_hints( workspace: Path, listing: list[str], notes: list[str] | None = None, ) -> None: note_block = "" if notes: note_block = ( "\n## Warnings\n\n" + "\n".join(notes) + "\n" ) (workspace / "PROJECT_FILES.md").write_text( ( "# Files in this workspace\n\n" "These are the only uploaded materials for this run. Read them from " "this folder only. Do not search parent directories, Helpomatica, " "Downloads, or invent client-PC paths. Names match the UI chips " "(for example `line_u_codes.log.1`), not hashed storage names.\n\n" f"{chr(10).join(listing) or '(no files copied)'}\n" f"{note_block}" ), encoding="utf-8", ) (workspace / "README.md").write_text( ( "# Helpomatica agent workspace\n\n" "This directory **is** your working directory (`cwd`). Files are " "already here under `./` and `./files/`.\n\n" "- FORBIDDEN: saying you left the workspace, that Shell is not in " "the workspace, or `cd` to Helpomatica / «папка проекта» / home / " "Downloads / parent folders.\n" "- Work only here. If a file is missing from `./` or `./files/`, " "say so — do not search elsewhere.\n" "- For `*.gz` logs: decompress with gzip/Python **only** on files " "inside this workspace (e.g. `./line_u.log.3.gz`).\n" "- If `./PREANALYSIS.md` exists, read it first. Do not re-scan whole logs " "unless those numbers look wrong. Cite log lines as `:15231:line_u_codes.log.1`.\n" ), encoding="utf-8", ) (workspace / "WORKSPACE_ROOT.txt").write_text( f"{workspace}\n", encoding="utf-8", ) (workspace / ".cursorignore").write_text( ( "# Isolated agent workspace. Do not treat the Helpomatica git repo\n" "# or parent folders as part of this project.\n" ), encoding="utf-8", ) cursor_dir = workspace / ".cursor" cursor_dir.mkdir(exist_ok=True) (cursor_dir / "workspace.md").write_text( ( "# cwd constraint\n\n" "Working directory is this temp workspace. Never leave it. Never " "say you left it. All in-scope uploads are in ./ and ./files/.\n" ), encoding="utf-8", ) (workspace / "AGENTS.md").write_text( ( "# Agent instructions\n\n" "Your shell cwd is this workspace. FORBIDDEN to `cd` to Helpomatica, " "home, or «папка проекта». Use only ./ and ./files/. If a file is " "missing, report that — do not search parent dirs. " "If PREANALYSIS.md is present, use it first.\n" ), encoding="utf-8", ) READABLE_EXTENSIONS = { ".csv", ".json", ".log", ".md", ".py", ".sql", ".txt", ".xml", ".yaml", ".yml" } MAX_PROMPT_FILE_BYTES = 2 * 1024 * 1024 DEFAULT_QUERY_KEYWORDS = ( "code", "codes", "insert", "create", "table", "barcode", "gtin", "баз", "код" ) SQL_FOCUS_PATTERNS = ( r"CREATE\s+FUNCTION[^\n]*codes_input_insert", r"CREATE\s+FUNCTION[^\n]*codes_input", r"CREATE\s+TABLE[^\n]*\bcodes\b", r"INSERT\s+INTO\s+codes\b", r"codes_input_meta", r"codes_output_insert", ) def is_gzip_name(name: str) -> bool: return str(name or "").lower().endswith(".gz") def is_prompt_readable_name(name: str) -> bool: """Rotated logs like ``.log.1`` are text; ``.gz`` is not inlined.""" lower = str(name or "").lower() if is_gzip_name(lower): return False suffix = Path(lower).suffix if suffix in READABLE_EXTENSIONS: return True return bool(re.search(r"\.log(\.\d+)?$", lower)) def decode_text(raw: bytes) -> str: for encoding in ("utf-8-sig", "utf-16", "cp1251"): try: return raw.decode(encoding) except UnicodeDecodeError: continue return "" def query_keywords(query: str) -> set[str]: words = {word.lower() for word in re.findall(r"[\w\u0400-\u04FF]+", query.lower()) if len(word) > 2} words.update(DEFAULT_QUERY_KEYWORDS) return words def extract_relevant_excerpt(text: str, query: str, max_chars: int) -> str: if len(text) <= max_chars: return text lines = text.splitlines() keywords = query_keywords(query) ranges: list[tuple[int, int, int]] = [] for pattern in SQL_FOCUS_PATTERNS: for match in re.finditer(pattern, text, flags=re.IGNORECASE): start_line = text[: match.start()].count("\n") ranges.append((10, max(0, start_line - 5), min(len(lines), start_line + 80))) for index, line in enumerate(lines): lower = line.lower() score = sum(1 for keyword in keywords if keyword in lower) if score: ranges.append((score, max(0, index - 10), min(len(lines), index + 25))) selected: list[str] = [] used = 0 header = "\n".join(lines[:30]) if header: selected.append(header) used += len(header) for _, start, end in sorted(ranges, key=lambda item: item[0], reverse=True): chunk = "\n".join(lines[start:end]) if not chunk or chunk in selected: continue if used + len(chunk) > max_chars: remaining = max_chars - used if remaining < 400: break chunk = chunk[:remaining] selected.append(chunk) used += len(chunk) if used >= max_chars: break if not selected: return text[:max_chars] + "\n[Файл сокращён]" excerpt = "\n\n".join(selected) if len(excerpt) < len(text): excerpt += "\n[Показаны только релевантные фрагменты файла]" return excerpt[:max_chars] def select_project_files( data: dict, project_id: str, query: str, chat_id: str | None = None, attachment_ids: list[str] | None = None, ) -> list[dict]: files = iter_scoped_files(data, project_id, chat_id) lowered = query.lower() if any(token in lowered for token in ("базов", "base")): filtered = [entry for entry in files if "base" in entry["name"].lower()] if filtered: files = filtered elif "2.10" in lowered: filtered = [entry for entry in files if "2.10" in entry["name"]] if filtered: files = filtered ranked = rank_files_for_workspace(files, query, attachment_ids) return dedupe_workspace_files(ranked) def build_sql_summary(text: str, query: str, max_chars: int) -> str: lines = text.splitlines() keywords = query_keywords(query) tables = [ line.strip() for line in lines if "CREATE TABLE" in line.upper() and any(keyword in line.lower() for keyword in keywords) ][:8] if not tables: tables = [ line.strip() for line in lines if "CREATE TABLE" in line.upper() and "codes" in line.lower() ][:8] functions = [ line.strip() for line in lines if "CREATE FUNCTION" in line.upper() and any(keyword in line.lower() for keyword in keywords) ][:6] triggers = [ line.strip() for line in lines if "TRIGGER" in line.upper() and any(keyword in line.lower() for keyword in keywords) ][:6] snippets: list[str] = [] for pattern in SQL_FOCUS_PATTERNS: for match in re.finditer(pattern, text, flags=re.IGNORECASE): start_line = text[: match.start()].count("\n") snippet = "\n".join(lines[start_line : start_line + 35]).strip() if snippet and snippet not in snippets: snippets.append(snippet[:700]) if len(snippets) >= 2: break if len(snippets) >= 2: break summary_parts = [] if tables: summary_parts.append("Таблицы:\n" + "\n".join(tables)) if functions: summary_parts.append("Функции:\n" + "\n".join(functions)) if triggers: summary_parts.append("Триггеры:\n" + "\n".join(triggers)) if snippets: summary_parts.append("Фрагменты SQL:\n" + "\n\n---\n\n".join(snippets)) summary = "\n\n".join(summary_parts) or text[:max_chars] return summary[:max_chars] def build_file_context( data: dict, project_id: str, query: str = "", chat_id: str | None = None, attachment_ids: list[str] | None = None, ) -> tuple[str, list[str]]: parts: list[str] = [] selected = select_project_files( data, project_id, query, chat_id=chat_id, attachment_ids=attachment_ids ) # Unique names for UI context chips (no eight copies of the same gz). used_names = [entry["name"] for entry in selected] remaining = 2_500 per_file = 1_200 for entry in selected: source = UPLOAD_DIR / entry["stored_name"] safe = sanitize_workspace_filename(entry["name"]) workspace_hint = f"Рабочая копия: ./files/{safe} и ./{safe}" exists = source.exists() and source.is_file() size = 0 if exists: try: size = source.stat().st_size except OSError: exists = False readable = is_prompt_readable_name(entry["name"]) if not exists or not readable: gz_hint = ( " Это .gz — распакуйте gzip/Python gzip только внутри workspace." if is_gzip_name(entry["name"]) else "" ) missing = "" if exists else " Файл не найден на диске сервера." parts.append( f"--- {entry['name']} ---\n" f"[Бинарный/нетекстовый файл: текст не встроен. {workspace_hint} — " f"откройте этот путь в workspace, не ищите файл на ПК пользователя." f"{gz_hint}{missing}]" ) continue if size > MAX_PROMPT_FILE_BYTES: parts.append( f"--- {entry['name']} ---\n" f"[Файл большой ({size} байт), текст не встроен в промпт. " f"{workspace_hint} — читайте его из workspace.]" ) continue if remaining <= 0: parts.append( f"--- {entry['name']} ---\n" f"[Файл есть в проекте, текст не включён из‑за лимита контекста. " f"{workspace_hint}]" ) continue text = decode_text(source.read_bytes()) if not text: parts.append( f"--- {entry['name']} ---\n" f"[Не удалось определить кодировку. {workspace_hint}]" ) continue budget = min(per_file, remaining) if str(entry["name"]).lower().endswith(".sql"): excerpt = build_sql_summary(text, query, budget) else: excerpt = extract_relevant_excerpt(text, query, budget) remaining -= len(excerpt) parts.append(f"--- {entry['name']} ---\n{workspace_hint}\n{excerpt}") return ("\n\n".join(parts) or "[Файлы не добавлены]", used_names) def _file_chip(entry: dict) -> dict: return {"id": entry.get("id") or "", "name": entry.get("name") or "файл"} def dedupe_file_chips(items: list[dict]) -> list[dict]: seen_ids: set[str] = set() seen_names: set[str] = set() unique: list[dict] = [] for item in items: fid = str((item or {}).get("id") or "") name = str((item or {}).get("name") or "").strip() key = name.lower() if fid and fid in seen_ids: continue if key and key in seen_names: continue if fid: seen_ids.add(fid) if key: seen_names.add(key) unique.append({"id": fid, "name": name or "файл"}) return unique def build_context_chip_groups( data: dict, project_id: str, chat_id: str | None, messages: list[dict], ) -> dict: last_user = next( (message for message in reversed(messages) if message.get("role") == "user"), {}, ) attachments = dedupe_file_chips( [ { "id": str((ref or {}).get("id") or ""), "name": str((ref or {}).get("name") or "файл"), } for ref in last_user.get("attachments") or [] ] ) attach_ids = {item["id"] for item in attachments if item["id"]} attach_names = {item["name"].lower() for item in attachments if item["name"]} chat_files: list[dict] = [] project_files: list[dict] = [] for entry in iter_scoped_files(data, project_id, chat_id): chip = _file_chip(entry) if chip["id"] in attach_ids or chip["name"].lower() in attach_names: continue if file_scope_of(entry) == "chat": chat_files.append(chip) else: project_files.append(chip) return { "attachments": attachments, "chat_files": dedupe_file_chips(chat_files), "project_files": dedupe_file_chips(project_files), } def _sdk_attr(obj: Any, *names: str, default: Any = "") -> Any: if obj is None: return default if isinstance(obj, Mapping): for name in names: if name in obj and obj[name] is not None: return obj[name] return default for name in names: if hasattr(obj, name): value = getattr(obj, name) if value is not None: return value return default def _assistant_text_from_sdk(message: Any) -> str: payload = _sdk_attr(message, "message", default=None) content = _sdk_attr(payload, "content", default=()) or () parts: list[str] = [] for block in content: text = _sdk_attr(block, "text", default="") if text: parts.append(str(text)) return "".join(parts) def _emit_text_chunk( parts: list[str], text: str, *, snapshot: bool = False, ) -> tuple[str, bool]: incoming = str(text or "") if not incoming: return "", False current = "".join(parts) if snapshot: if incoming == current: return "", False if incoming.startswith(current): chunk = incoming[len(current) :] if chunk: parts.append(chunk) return chunk, False parts.clear() parts.append(incoming) return incoming, True parts.append(incoming) return incoming, False _JUNK_STATUS_TOKENS = { "finished", "running", "done", "completed", "complete", "success", "ok", "ready", "idle", "завершено", "готово", } _PROMPT_LEFTOVER_RE = re.compile( r"^\s*(на русском языке\.?\s*)+", re.IGNORECASE, ) _FINISHED_TOKEN_RE = re.compile(r"(?i)\b(FINISHED|RUNNING)\b") _TOOL_TITLES = { "read": "Читаю файл", "readfile": "Читаю файл", "shell": "Команда", "bash": "Команда", "command": "Команда", "grep": "Поиск", "rg": "Поиск", "glob": "Поиск файлов", "write": "Запись файла", "edit": "Правка", "delete": "Удаление", "ls": "Список файлов", "readlints": "Проверка", "mcp": "Инструмент", "semsearch": "Семантический поиск", "createplan": "План", "updatetodos": "Задачи", "task": "Задача", "generateimage": "Изображение", "recordscreen": "Запись экрана", } def _is_junk_status(text: str) -> bool: normalized = re.sub(r"[^a-zа-яё]+", "", (text or "").strip().lower()) return normalized in _JUNK_STATUS_TOKENS def _clean_thinking_chunk(text: str) -> str: value = str(text or "") kept: list[str] = [] for line in value.splitlines(keepends=True): stripped = line.strip() if _is_junk_status(stripped): continue kept.append(_FINISHED_TOKEN_RE.sub("", line)) value = "".join(kept) value = _PROMPT_LEFTOVER_RE.sub("", value) if _is_junk_status(value): return "" return value def _suffix_prefix_overlap(current: str, incoming: str) -> int: max_n = min(len(current), len(incoming)) for length in range(max_n, 0, -1): if current.endswith(incoming[:length]): return length return 0 def _append_thinking_chunk(parts: list[str], text: str, *, snapshot: bool = False) -> str: """Append thinking text. Never replace previously accumulated history.""" incoming = _clean_thinking_chunk(text) if not incoming: return "" current = "".join(parts) if snapshot: if not current: chunk = incoming elif incoming == current or current.endswith(incoming): return "" elif incoming.startswith(current): chunk = incoming[len(current) :] elif incoming in current: return "" else: overlap = _suffix_prefix_overlap(current, incoming) if overlap == len(incoming): return "" if overlap > 0: chunk = incoming[overlap:] else: sep = "" if current.endswith("\n") else "\n" chunk = f"{sep}{incoming}" else: chunk = incoming if not chunk: return "" parts.append(chunk) return chunk def _as_dict(value: Any) -> dict[str, Any]: if isinstance(value, Mapping): return dict(value) return {} def _short_path(value: str) -> str: text = str(value or "").strip().replace("\\", "/") if not text: return "" return text.rstrip("/").rsplit("/", 1)[-1] or text def _normalize_tool_type(value: str) -> str: key = re.sub(r"[^a-zA-Z]", "", str(value or "")).lower() return key or "tool" def _tool_title(tool_type: str) -> str: return _TOOL_TITLES.get(_normalize_tool_type(tool_type), "Инструмент") def _truncate_detail(text: str, limit: int = 4000) -> str: value = str(text or "").strip() if len(value) <= limit: return value return value[:limit].rstrip() + "\n…" def _tool_detail(tool_type: str, args: Any) -> str: payload = _as_dict(args) kind = _normalize_tool_type(tool_type) if kind in {"read", "readfile", "write", "edit", "delete"}: return _short_path( str( payload.get("path") or payload.get("file") or payload.get("target_file") or "" ) ) if kind in {"shell", "bash", "command"}: return str(payload.get("command") or payload.get("cmd") or "").strip() if kind in {"grep", "rg"}: pattern = str(payload.get("pattern") or payload.get("query") or "").strip() path = _short_path(str(payload.get("path") or payload.get("glob") or "")) return f"{pattern} {path}".strip() if path else pattern if kind == "glob": return str( payload.get("globPattern") or payload.get("glob_pattern") or payload.get("pattern") or "" ).strip() if kind == "ls": return str(payload.get("path") or payload.get("targetDirectory") or "").strip() if kind == "semsearch": return str(payload.get("query") or payload.get("pattern") or "").strip() if kind == "mcp": name = str(payload.get("toolName") or payload.get("name") or "").strip() server = str(payload.get("server") or payload.get("provider") or "").strip() return f"{server}/{name}".strip("/") if server or name else "" if kind == "task": return str(payload.get("description") or payload.get("prompt") or "").strip() for key in ("path", "command", "pattern", "query", "name", "message"): item = payload.get(key) if item: return str(item).strip() return "" def _describe_tool(tool_call: Any, name_fallback: str = "") -> tuple[str, str, str]: payload = _as_dict(tool_call) tool_type = str( payload.get("type") or payload.get("name") or name_fallback or "tool" ) args = payload.get("args") if args is None: args = payload title = _tool_title(tool_type) if title == "Инструмент" and name_fallback: title = _tool_title(name_fallback) if title == "Инструмент": title = str(name_fallback).strip() or "Инструмент" detail = _truncate_detail(_tool_detail(tool_type, args), 1200) return _normalize_tool_type(tool_type) or "tool", title, detail def _activity_transcript(activity: list[dict[str, Any]]) -> str: lines: list[str] = [] for step in activity: kind = str(step.get("type") or "") title = str(step.get("title") or "").strip() detail = str(step.get("detail") or "").strip() if kind == "thinking": if detail: lines.append(detail) continue if title and detail: lines.append(f"{title}: {detail}") elif title: lines.append(title) elif detail: lines.append(detail) return "\n".join(lines).strip() def _coalesce_thinking_steps(activity: list[dict[str, Any]]) -> list[dict[str, Any]]: merged: list[dict[str, Any]] = [] for step in activity: kind = str(step.get("type") or "") detail = str(step.get("detail") or "") if kind in {"thinking", "status"} and _is_junk_status(detail): continue last = merged[-1] if merged else None if kind == "thinking" and last is not None and str(last.get("type") or "") == "thinking": prev = str(last.get("detail") or "") if detail: if detail == prev or prev.endswith(detail) or detail in prev: pass elif detail.startswith(prev): last["detail"] = detail else: last["detail"] = prev + detail last["status"] = step.get("status") or last.get("status") if step.get("title"): last["title"] = step.get("title") continue merged.append(dict(step)) return merged def _trim_activity(activity: list[dict[str, Any]]) -> list[dict[str, Any]]: coalesced = _coalesce_thinking_steps(activity) trimmed: list[dict[str, Any]] = [] for step in coalesced[-240:]: entry = { "id": step.get("id"), "type": step.get("type"), "title": step.get("title"), "detail": _truncate_detail(str(step.get("detail") or ""), 8000), "status": step.get("status"), } trimmed.append(entry) return trimmed def build_chat_prompt( project: dict, file_context: str, history: str, extra: str = "", ) -> str: extra_block = f"\n{extra.strip()}\n\n" if (extra or "").strip() else "" return ( f"Проект: {project['name']}\n" f"Описание: {project['description']}\n" f"Инструкции: {project['instructions'] or 'Нет'}\n\n" f"Материалы:\n{file_context}\n\n" f"История:\n{history}\n\n" "Правила платформы Helpomatica:\n" "- Текущая рабочая директория (cwd) УЖЕ является изолированным " "workspace агента. Файлы УЖЕ лежат в cwd: ./имя_файла и ./files/имя_файла " "(см. PROJECT_FILES.md). PREANALYSIS.md и логи тоже в `./`. " "Работай только там.\n" "- ЗАПРЕЩЕНО: писать, что Shell/cwd «не в workspace», что ты «уходишь " "из workspace», «переходишь к рабочей папке проекта», делаешь `cd` в " "Helpomatica, домашнюю папку, Downloads или родительские каталоги. " "Не выполняй `cd` вообще. cwd уже правильный. Никогда не пиши, что " "shell покинул workspace.\n" "- Если файла нет в ./ или ./files/ — скажи, что его нет среди " "загруженных материалов. НЕ ищи в родительских папках, в git-репозитории " "Helpomatica, на диске сервера и на ПК пользователя " "(Downloads, C:\\Users\\..., /home/...). Не выдумывай абсолютные пути.\n" "- Имена файлов совпадают с чипами в UI (например line_u.log.3.gz), " "а не с hashed stored_name. Пользователь мог открыть Helpomatica с " "другого ПК по LAN — путей с его компьютера не существует.\n" "- Для логов *.gz / *.log.*.gz: распаковывай и читай только файлы " "внутри workspace (gzip, Python gzip на ./файл.gz).\n" "- Если есть ./PREANALYSIS.md — прочитай его ПЕРВЫМ. Не сканируй " "логи целиком, если цифры выглядят правдоподобно. Цитируй строки как " "`:15231:line_u_codes.log.1` или `::log:line_u_codes.log.1:15231::`.\n" "- Генерация изображений недоступна: в Helpomatica нет image API и нет " "инструмента создания картинок. Не утверждай, что сгенерировал, нарисовал " "или сохранил изображение. Не вставляй markdown-изображения без реального " "доступного URL. Если пользователь просит картинку — честно скажи, что " "генерация изображений недоступна, и предложи текстовое описание или " "mermaid-диаграмму.\n" "- Отвечай только полезным текстом/markdown/mermaid по существу вопроса.\n\n" f"{extra_block}" "Ответь кратко и по делу на последнее сообщение пользователя." ) def normalize_analysis_question(text: str) -> str: value = _ATTACHED_NAMES_RE.sub("", str(text or "")) value = re.sub(r"\s+", " ", value).strip().lower() return value def analysis_file_signature(files: list[dict]) -> str: parts = [ f"{entry.get('id')}:{entry.get('size', 0)}" for entry in sorted(files, key=lambda item: str(item.get("id") or "")) if entry.get("id") ] return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() def analysis_cache_key(files: list[dict], question: str) -> str: raw = f"{analysis_file_signature(files)}|{normalize_analysis_question(question)}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def lookup_analysis_cache( data: dict, project_id: str, files: list[dict], question: str, ) -> dict | None: cache = data.get("analysis_cache") or [] key = analysis_cache_key(files, question) for entry in reversed(cache): if entry.get("project_id") == project_id and entry.get("key") == key: return entry job_id = log_preanalysis.detect_job_id(question) file_sig = analysis_file_signature(files) if not job_id or not file_sig: return None for entry in reversed(cache): if ( entry.get("project_id") == project_id and entry.get("file_sig") == file_sig and entry.get("job_id") == job_id ): near = dict(entry) near["near_match"] = True return near return None def store_analysis_cache( project_id: str, files: list[dict], question: str, answer: str, preanalysis: str = "", summary: str = "", ) -> None: answer = (answer or "").strip() if not answer: return data = load_db() entry = { "key": analysis_cache_key(files, question), "file_sig": analysis_file_signature(files), "project_id": project_id, "job_id": log_preanalysis.detect_job_id(question) or "", "question": normalize_analysis_question(question)[:500], "answer": answer[:8000], "preanalysis": (preanalysis or "")[:80_000], "summary": (summary or "")[:2000], "created_at": now(), } cache = [ item for item in (data.get("analysis_cache") or []) if not ( item.get("project_id") == project_id and item.get("key") == entry["key"] ) ] cache.append(entry) data["analysis_cache"] = cache[-80:] save_db(data) def _prompt_extra_from_cache_and_scan( cache_hit: dict | None, preanalysis_md: str, ) -> str: parts: list[str] = [] if preanalysis_md: parts.append( "В cwd уже лежит ./PREANALYSIS.md — используй его первым, " "не перечитывай логи целиком." ) if cache_hit and (cache_hit.get("answer") or cache_hit.get("summary")): if cache_hit.get("near_match"): parts.append( "Похожий предыдущий разбор по тем же файлам (не пересчитывай " "с нуля, уточни если вопрос уже):\n" f"{(cache_hit.get('summary') or '')}\n\n" f"{(cache_hit.get('answer') or '')[:6000]}" ) else: parts.append( "Предыдущий разбор (не пересчитывай с нуля, уточни если вопрос уже):\n" f"{(cache_hit.get('summary') or '')}\n\n" f"{(cache_hit.get('answer') or '')[:6000]}\n\n" "Вопрос совпадает — ответь короче, без полного пересчёта." ) return "\n\n".join(parts) def resolve_upload_path(entry: dict) -> Path: return UPLOAD_DIR / str(entry.get("stored_name") or "") def log_display_name(entry: dict) -> str: return sanitize_workspace_filename( _strip_upload_uuid_prefixes(str(entry.get("name") or "log")) ) def file_in_user_scope(data: dict, stored_file: dict) -> bool: """Any authenticated user can see project files; chat-scoped files stay in-project.""" if not stored_file: return False project_id = stored_file.get("project_id") if not project_id: return False return any(item.get("id") == project_id for item in data.get("projects") or []) def snippet_around(text: str, needle: str, radius: int = 70) -> str: raw = str(text or "").replace("\n", " ") lowered = raw.lower() index = lowered.find(needle.lower()) if index < 0: return raw[: radius * 2] start = max(0, index - radius) end = min(len(raw), index + len(needle) + radius) snippet = raw[start:end].strip() if start > 0: snippet = "…" + snippet if end < len(raw): snippet = snippet + "…" return snippet def attachment_filename(filename: str, fallback: str = "download") -> str: name = (filename or fallback).replace("\r", " ").replace("\n", " ").strip() or fallback ascii_name = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._") or fallback return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}" def export_chat_markdown(chat: dict, project: dict, messages: list[dict]) -> str: title = chat.get("title") or "Чат" lines = [ f"# {title}", "", f"- Проект: {project.get('name') or ''}", f"- Обновлён: {chat.get('updated_at') or ''}", "", ] for message in messages: role = "Помощник" if message.get("role") == "assistant" else ( message.get("author_name") or "Пользователь" ) created = message.get("created_at") or "" lines.append(f"## {role}") if created: lines.append(f"*{created}*") lines.append("") attachments = message.get("attachments") or [] if attachments: names = ", ".join( str(item.get("name") or item.get("id") or "файл") for item in attachments ) lines.append(f"Вложения: {names}") lines.append("") lines.append(str(message.get("content") or "").strip() or "_(пусто)_") lines.append("") return "\n".join(lines).rstrip() + "\n" def sse_format(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" def _put_event(out: queue.Queue, event_type: str, **payload: Any) -> None: out.put({"type": event_type, **payload}) def _stream_cursor_agent_threaded( prompt: str, workspace: Path, model: str, chat_id: str, out: queue.Queue, cancel_event: threading.Event, ) -> None: if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) async def _run() -> None: from cursor_sdk import ( AgentOptions, AsyncAgent, AsyncClient, CursorAgentError, LocalAgentOptions, ) api_key = os.getenv("CURSOR_API_KEY", "").strip() if not api_key: raise RuntimeError( "CURSOR_API_KEY не настроен. Добавьте ключ в файл .env и перезапустите сервер." ) loop = asyncio.get_running_loop() with agent_status_lock: entry = active_runs.get(chat_id) if entry is not None: entry["loop"] = loop answer_parts: list[str] = [] thinking_parts: list[str] = [] activity_log: list[dict[str, Any]] = [] thinking_ms: int | None = None cancelled = False activity_seq = 0 current_thinking_id: str | None = None tool_activity_ids: dict[str, str] = {} seen_tool_keys: set[tuple[str, str]] = set() def emit_thinking(text: str) -> None: if not text: return _put_event(out, "thinking", text=text, replace=False) def append_thinking_transcript(text: str) -> None: chunk = _append_thinking_chunk(thinking_parts, text, snapshot=False) if chunk: emit_thinking(chunk) def emit_answer(text: str, *, replace: bool = False) -> None: if not text and not replace: return _put_event(out, "delta", text=text, replace=replace) def emit_activity(step: dict[str, Any]) -> dict[str, Any]: nonlocal activity_seq payload = dict(step) if not payload.get("id"): activity_seq += 1 payload["id"] = f"a{activity_seq}" existing = next( (item for item in activity_log if item.get("id") == payload["id"]), None, ) if existing is not None: if payload.get("append") and payload.get("detail"): existing["detail"] = str(existing.get("detail") or "") + str( payload.get("detail") or "" ) payload["detail"] = existing["detail"] payload.pop("append", None) else: for key, value in payload.items(): if value is not None: existing[key] = value payload = dict(existing) else: payload.pop("append", None) activity_log.append(payload) _put_event(out, "activity", step=payload) return payload def handle_thinking_text(text: str, *, snapshot: bool = False) -> None: nonlocal current_thinking_id chunk = _append_thinking_chunk( thinking_parts, text, snapshot=snapshot ) if not chunk: return emit_thinking(chunk) if not current_thinking_id: last = activity_log[-1] if activity_log else None if last is not None and str(last.get("type") or "") == "thinking": current_thinking_id = str(last.get("id") or "") or None if current_thinking_id: emit_activity( { "id": current_thinking_id, "type": "thinking", "title": "Размышления", "detail": chunk, "status": "running", "append": True, } ) else: step = emit_activity( { "type": "thinking", "title": "Размышления", "detail": chunk, "status": "running", } ) current_thinking_id = str(step["id"]) def close_thinking_step(*, end_phase: bool = True) -> None: nonlocal current_thinking_id if not current_thinking_id: return emit_activity( { "id": current_thinking_id, "type": "thinking", "title": "Размышления", "status": "completed", } ) if end_phase: current_thinking_id = None def handle_answer_text(text: str, *, snapshot: bool | None = False) -> None: incoming = str(text or "") if not incoming: return current = "".join(answer_parts) use_snapshot = snapshot if snapshot is None: if not current: use_snapshot = False elif incoming == current: return elif incoming.startswith(current): use_snapshot = True elif current.startswith(incoming): return else: use_snapshot = False chunk, replace = _emit_text_chunk( answer_parts, incoming, snapshot=bool(use_snapshot) ) if chunk or replace: emit_answer(chunk, replace=replace) def flush_answer_to_thinking() -> None: text = "".join(answer_parts).strip() if not text: return close_thinking_step() emit_activity( { "type": "narration", "title": "Заметка", "detail": _truncate_detail(text, 4000), "status": "completed", } ) append_thinking_transcript(f"{text}\n") answer_parts.clear() emit_answer("", replace=True) def handle_tool_event( tool_call: Any, *, name: str = "", status: str = "", call_id: str = "", ) -> None: state = (status or "running").strip().lower() if state in ("complete", "success"): state = "completed" ident = str(call_id or _sdk_attr(tool_call, "call_id", "callId", default="") or "") already = bool(ident) and (ident, state) in seen_tool_keys if ident: seen_tool_keys.add((ident, state)) kind, title, detail = _describe_tool(tool_call, name) if not ident: for item in reversed(activity_log[-12:]): if ( item.get("title") == title and (item.get("detail") or "") == (detail or "") and (item.get("status") or "") == (state or "running") ): return step_id = tool_activity_ids.get(ident) if ident else None if already and state == "running": if step_id and detail: emit_activity( { "id": step_id, "type": kind, "title": title, "detail": detail, "status": "running", } ) return flush_answer_to_thinking() close_thinking_step() if state == "running" or not step_id: step = emit_activity( { "id": step_id, "type": kind, "title": title, "detail": detail, "status": state or "running", } ) if ident: tool_activity_ids[ident] = str(step["id"]) if not already: line = f"{title}" + (f": {detail}" if detail else "") append_thinking_transcript(f"{line}\n") return emit_activity( { "id": step_id, "type": kind, "title": title, "detail": detail or None, "status": state, } ) def handle_status_event(status: str, message: str = "") -> None: label = str(message or status or "").strip() if not label or _is_junk_status(label) or _is_junk_status(status): return close_thinking_step() emit_activity( { "type": "status", "title": "Статус", "detail": label, "status": "completed", } ) append_thinking_transcript(f"{label}\n") def handle_shell_output(payload: Any) -> None: data = _as_dict(payload) chunk = str( data.get("stdout") or data.get("text") or data.get("data") or data.get("output") or "" ) if not chunk: return last_shell = next( ( item for item in reversed(activity_log) if item.get("type") in {"shell", "bash", "command"} ), None, ) if last_shell is None: return emit_activity( { "id": last_shell.get("id"), "type": last_shell.get("type"), "title": last_shell.get("title") or "Команда", "detail": chunk, "status": last_shell.get("status") or "running", "append": True, } ) # Same resolved absolute long path for bridge --workspace AND # LocalAgentOptions.cwd (and Agent.create local=). Short 8.3 paths # make the agent think the shell left the workspace. # The SDK bridge subprocess inherits the server cwd (Helpomatica repo) # unless we set it explicitly — patch create_subprocess_exec for launch. workspace_cwd = agent_workspace_cwd(workspace) local_opts = LocalAgentOptions(cwd=workspace_cwd) logger.info("agent launch workspace=%s", workspace_cwd) import cursor_sdk._async_bridge as _async_bridge orig_exec = _async_bridge.asyncio.create_subprocess_exec async def _exec_in_workspace(*args: Any, **kwargs: Any): kwargs.setdefault("cwd", workspace_cwd) return await orig_exec(*args, **kwargs) _async_bridge.asyncio.create_subprocess_exec = _exec_in_workspace try: bridge_client = await AsyncClient.launch_bridge( workspace=workspace_cwd, local=local_opts, ) finally: _async_bridge.asyncio.create_subprocess_exec = orig_exec try: async with bridge_client as client: await asyncio.sleep(0.75) agent = await AsyncAgent.create( AgentOptions( api_key=api_key, model=model or default_model(), local=local_opts, ), client=client, ) try: run = await agent.send(prompt) with agent_status_lock: entry = active_runs.get(chat_id) if entry is not None: entry["run"] = run async for event in run.events(): if cancel_event.is_set(): cancelled = True try: if run.supports("cancel"): await run.cancel() except Exception as cancel_error: # noqa: BLE001 logger.warning("cancel failed: %s", cancel_error) break update = getattr(event, "interaction_update", None) if update is not None: utype = str(_sdk_attr(update, "type", default="")) nested = _sdk_attr(update, "update", default=None) if utype == "thinking-delta": handle_thinking_text( str(_sdk_attr(update, "text", default="") or ""), snapshot=False, ) elif utype == "thinking-completed": close_thinking_step(end_phase=False) duration = _sdk_attr( update, "thinking_duration_ms", "thinkingDurationMs", default=None, ) if duration is not None: try: thinking_ms = int(duration) except (TypeError, ValueError): pass elif utype == "text-delta": handle_answer_text( str(_sdk_attr(update, "text", default="") or ""), snapshot=False, ) elif utype in ( "tool-call-started", "tool-call-completed", "partial-tool-call", ): tool_call = _sdk_attr(update, "tool_call", default={}) name = str( _sdk_attr(tool_call, "type", "name", default="") or _sdk_attr(update, "name", default="") ) status = ( "running" if utype in ("tool-call-started", "partial-tool-call") else "completed" ) handle_tool_event( tool_call, name=name, status=status, call_id=str( _sdk_attr( update, "call_id", "callId", default="" ) or "" ), ) elif utype == "shell-output-delta": handle_shell_output( _sdk_attr(update, "event", default={}) ) elif utype == "summary": summary = str( _sdk_attr(update, "summary", default="") or "" ).strip() if summary: close_thinking_step() emit_activity( { "type": "status", "title": "Сводка", "detail": _truncate_detail(summary, 2000), "status": "completed", } ) append_thinking_transcript(f"{summary}\n") elif utype in ( "token-delta", "step-started", "step-completed", "turn-ended", "user-message-appended", "summary-started", "summary-completed", ): pass elif isinstance(nested, Mapping): nested_type = str(nested.get("type") or utype) nested_text = str(nested.get("text") or "") if nested_type == "thinking-delta" and nested_text: handle_thinking_text(nested_text, snapshot=False) elif nested_type == "text-delta" and nested_text: handle_answer_text(nested_text, snapshot=False) elif nested_type in ( "tool-call-started", "tool-call-completed", "partial-tool-call", ): handle_tool_event( nested.get("toolCall") or nested.get("tool_call") or nested, name=str(nested.get("name") or ""), status=( "completed" if nested_type == "tool-call-completed" else "running" ), call_id=str( nested.get("callId") or nested.get("call_id") or "" ), ) elif utype: detail = str( _sdk_attr(update, "text", "message", default="") or "" ).strip() if detail and not _is_junk_status(detail): close_thinking_step() emit_activity( { "type": "status", "title": utype, "detail": _truncate_detail(detail, 800), "status": "running", } ) message = getattr(event, "sdk_message", None) if message is not None: mtype = str(_sdk_attr(message, "type", default="")) if mtype == "thinking": handle_thinking_text( str(_sdk_attr(message, "text", default="") or ""), snapshot=True, ) duration = _sdk_attr( message, "thinking_duration_ms", "thinkingDurationMs", default=None, ) if duration is not None: try: thinking_ms = int(duration) except (TypeError, ValueError): pass close_thinking_step(end_phase=False) elif mtype == "assistant": handle_answer_text( _assistant_text_from_sdk(message), snapshot=None, ) elif mtype == "tool_call": handle_tool_event( { "type": _sdk_attr(message, "name", default=""), "args": _sdk_attr(message, "args", default={}), }, name=str(_sdk_attr(message, "name", default="")), status=str( _sdk_attr(message, "status", default="") ), call_id=str( _sdk_attr( message, "call_id", "callId", default="" ) or "" ), ) elif mtype in ("status", "task"): handle_status_event( str(_sdk_attr(message, "status", default="") or ""), str( _sdk_attr( message, "message", "text", default="" ) or "" ), ) step = getattr(event, "step", None) if step is not None: stype = str(_sdk_attr(step, "type", default="")) step_message = _sdk_attr(step, "message", default=None) if stype == "thinkingMessage": handle_thinking_text( str( _sdk_attr(step_message, "text", default="") or "" ), snapshot=True, ) duration = _sdk_attr( step_message, "thinking_duration_ms", "thinkingDurationMs", default=None, ) if duration is not None: try: thinking_ms = int(duration) except (TypeError, ValueError): pass close_thinking_step(end_phase=False) elif stype == "assistantMessage": handle_answer_text( str( _sdk_attr(step_message, "text", default="") or "" ), snapshot=None, ) elif stype == "toolCall": handle_tool_event( step_message, name=str( _sdk_attr(step_message, "name", "type", default="") ), status=str( _sdk_attr(step_message, "status", default="") ), call_id=str( _sdk_attr( step_message, "call_id", "callId", default="", ) or "" ), ) if not cancelled: result = await run.wait() if str(result.status).lower().endswith("error"): raise RuntimeError( f"Cursor Agent завершил работу с ошибкой (run: {result.id})." ) if not answer_parts and (result.result or "").strip(): handle_answer_text( (result.result or "").strip(), snapshot=True ) finally: try: await agent.close() except Exception: # noqa: BLE001 pass except (CursorAgentError, OSError) as error: raise CursorStartupError(format_agent_error(error)) from error close_thinking_step() answer = "".join(answer_parts).strip() activity = _trim_activity(activity_log) thinking = "".join(thinking_parts).strip() or _activity_transcript(activity) if thinking_ms is not None: _put_event(out, "thinking_done", duration_ms=thinking_ms) if cancelled: _put_event( out, "cancelled", text=answer, thinking=thinking, thinking_duration_ms=thinking_ms, activity=activity, ) return if not answer: raise RuntimeError("Cursor Agent не вернул текстовый ответ.") _put_event( out, "done", text=answer, thinking=thinking, thinking_duration_ms=thinking_ms, activity=activity, ) try: from cursor_sdk import AsyncClient # noqa: F401 except ImportError as error: _put_event( out, "error", detail="Пакет cursor-sdk не найден. Запустите сервер через .\\run.ps1", ) return try: asyncio.run(_run()) except CursorStartupError as error: _put_event(out, "error", detail=f"Cursor Agent не запустился: {error}", status=502) except Exception as error: # noqa: BLE001 _put_event(out, "error", detail=format_agent_error(error), status=503) finally: out.put(None) def claim_chat_slot(chat_id: str) -> tuple[str, threading.Event]: """Return ('run'|'wait', turn_event). Raise 409 if queue full.""" with chat_queue_lock: state = chat_queues.setdefault( chat_id, {"running": False, "waiting": 0, "turn": threading.Event()}, ) if "turn" not in state: state["turn"] = threading.Event() if not state["running"]: state["running"] = True state["turn"].clear() return "run", state["turn"] if int(state["waiting"]) >= 1: raise HTTPException( status_code=409, detail="В этом чате уже есть запрос в очереди. Дождитесь ответа.", ) state["waiting"] = 1 return "wait", state["turn"] def abandon_waiting_slot(chat_id: str) -> None: with chat_queue_lock: state = chat_queues.get(chat_id) if not state: return state["waiting"] = max(0, int(state["waiting"]) - 1) def release_running_slot(chat_id: str) -> None: with chat_queue_lock: state = chat_queues.get(chat_id) if not state: return if int(state["waiting"]) > 0: state["waiting"] = 0 # Promote waiter: keep running=True and signal turn. state["turn"].set() return state["running"] = False state["turn"].set() async def wait_chat_turn( turn_event: threading.Event, cancel_event: threading.Event, request: Request, ) -> bool: while not turn_event.is_set(): if cancel_event.is_set() or await request.is_disconnected(): return False await asyncio.sleep(0.15) return not cancel_event.is_set() def register_active_run(chat_id: str, cancel_event: threading.Event, user_id: str) -> None: with agent_status_lock: active_runs[chat_id] = { "cancel_event": cancel_event, "run": None, "loop": None, "thread": None, "user_id": user_id, } def unregister_active_run(chat_id: str, cancel_event: threading.Event) -> None: with agent_status_lock: entry = active_runs.get(chat_id) if entry is not None and entry.get("cancel_event") is cancel_event: active_runs.pop(chat_id, None) def sdk_available() -> bool: try: import cursor_sdk # noqa: F401 return True except ImportError: return False class LoginRequest(BaseModel): username: str password: str class UserCreate(BaseModel): username: str password: str display_name: str = "" role: str = "user" class ProjectCreate(BaseModel): name: str description: str = "" instructions: str = "" icon: str = "✦" model: str | None = None class ProjectUpdate(BaseModel): name: str | None = None description: str | None = None instructions: str | None = None icon: str | None = None model: str | None = None class ChatCreate(BaseModel): project_id: str title: str = "Новый чат" class ChatUpdate(BaseModel): title: str | None = None class ChatOrderUpdate(BaseModel): chat_ids: list[str] class AttachmentRef(BaseModel): id: str name: str = "" class MessageCreate(BaseModel): role: str = "user" content: str attachments: list[AttachmentRef] = [] class BindFilesPayload(BaseModel): file_ids: list[str] = [] app = FastAPI(title="Helpomatica", version="1.1.0") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path if path.startswith("/api/") and path != "/api/auth/login": user = resolve_session(request.cookies.get(SESSION_COOKIE)) if not user: return JSONResponse(status_code=401, content={"detail": "Требуется вход"}) request.state.user = user return await call_next(request) @app.on_event("startup") def configure_logging() -> None: logging.basicConfig(level=logging.INFO) load_db() api_key = os.getenv("CURSOR_API_KEY", "").strip() if not api_key: logger.warning("CURSOR_API_KEY не задан в .env") else: logger.info("CURSOR_API_KEY загружен (%s..., len=%s)", api_key[:8], len(api_key)) try: import cursor_sdk logger.info("cursor_sdk: %s", cursor_sdk.__file__) except ImportError as error: logger.error("cursor_sdk недоступен: %s", error) @app.get("/") def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") @app.post("/api/auth/login") def login(payload: LoginRequest, response: Response) -> dict: username = payload.username.strip() password = payload.password if not username or not password: raise HTTPException(status_code=422, detail="Введите логин и пароль") data = load_db() user = next( (entry for entry in data["users"] if entry["username"].lower() == username.lower()), None, ) if not user or not verify_password(password, user["password_hash"]): raise HTTPException(status_code=401, detail="Неверный логин или пароль") session_id = secrets.token_urlsafe(32) data["sessions"].append( { "id": session_id, "user_id": user["id"], "created_at": now(), } ) # Keep sessions list bounded. if len(data["sessions"]) > 200: data["sessions"] = data["sessions"][-200:] save_db(data) response.set_cookie( key=SESSION_COOKIE, value=session_id, httponly=True, samesite="lax", max_age=SESSION_DAYS * 24 * 60 * 60, path="/", ) return public_user(user) @app.post("/api/auth/logout") def logout(request: Request, response: Response) -> dict: session_id = request.cookies.get(SESSION_COOKIE) if session_id: data = load_db() data["sessions"] = [s for s in data["sessions"] if s["id"] != session_id] save_db(data) response.delete_cookie(SESSION_COOKIE, path="/") return {"ok": True} @app.get("/api/auth/me") def me(user: dict = Depends(get_current_user)) -> dict: return public_user(user) @app.get("/api/users") def list_users(_: dict = Depends(require_admin)) -> list[dict]: data = load_db() return [public_user(user) for user in sorted(data["users"], key=lambda u: u["created_at"])] @app.post("/api/users", status_code=201) def create_user(payload: UserCreate, _: dict = Depends(require_admin)) -> dict: username = payload.username.strip() password = payload.password display_name = (payload.display_name or username).strip() role = payload.role if payload.role in ("admin", "user") else "user" if not username or not password: raise HTTPException(status_code=422, detail="Логин и пароль обязательны") if len(password) < 4: raise HTTPException(status_code=422, detail="Пароль слишком короткий") data = load_db() if any(u["username"].lower() == username.lower() for u in data["users"]): raise HTTPException(status_code=409, detail="Пользователь уже существует") user = { "id": uuid.uuid4().hex, "username": username, "display_name": display_name or username, "password_hash": hash_password(password), "role": role, "created_at": now(), } data["users"].append(user) save_db(data) return public_user(user) @app.get("/api/agent/status") def agent_status(user: dict = Depends(get_current_user)) -> dict: api_key = bool(os.getenv("CURSOR_API_KEY", "").strip()) with agent_status_lock: # Exclude wait:* placeholders — only real running agents. active_chat_ids = [ key for key in active_runs.keys() if not str(key).startswith("wait:") ] busy = len(active_chat_ids) > 0 with chat_queue_lock: queued = { chat_id: {"running": bool(state["running"]), "waiting": int(state["waiting"])} for chat_id, state in chat_queues.items() if state["running"] or int(state["waiting"]) > 0 } return { "key_ok": api_key, "sdk_ok": sdk_available(), "busy": busy, "state": "busy" if busy else "idle", "active_chats": active_chat_ids, "active_count": len(active_chat_ids), "queues": queued, "model_default": default_model(), "user_id": user["id"], } @app.get("/api/projects") def list_projects(_: dict = Depends(get_current_user)) -> list[dict]: data = load_db() projects = [] for project in data["projects"]: enriched = project.copy() enriched["chat_count"] = sum( chat["project_id"] == project["id"] for chat in data["chats"] ) enriched["file_count"] = sum( file["project_id"] == project["id"] for file in data["files"] ) projects.append(enriched) return sorted(projects, key=lambda item: item["updated_at"], reverse=True) @app.post("/api/projects", status_code=201) def create_project(payload: ProjectCreate, user: dict = Depends(get_current_user)) -> dict: name = payload.name.strip() if not name: raise HTTPException(status_code=422, detail="Введите название проекта") data = load_db() timestamp = now() project = { "id": uuid.uuid4().hex, "name": name, "description": payload.description.strip(), "instructions": payload.instructions.strip(), "icon": payload.icon or "✦", "model": (payload.model or default_model()).strip() or default_model(), "created_by": user["id"], "created_at": timestamp, "updated_at": timestamp, } data["projects"].append(project) save_db(data) return project @app.patch("/api/projects/{project_id}") def update_project( project_id: str, payload: ProjectUpdate, _: dict = Depends(get_current_user), ) -> dict: data = load_db() project = find(data["projects"], project_id, "Проект") for key, value in payload.model_dump(exclude_none=True).items(): project[key] = value.strip() if isinstance(value, str) else value if not project["name"]: raise HTTPException(status_code=422, detail="Название не может быть пустым") if not project.get("model"): project["model"] = default_model() project["updated_at"] = now() save_db(data) return project @app.delete("/api/projects/{project_id}", status_code=204) def delete_project(project_id: str, _: dict = Depends(get_current_user)) -> None: data = load_db() find(data["projects"], project_id, "Проект") chat_ids = { chat["id"] for chat in data["chats"] if chat["project_id"] == project_id } for stored_file in [ file for file in data["files"] if file["project_id"] == project_id ]: (UPLOAD_DIR / stored_file["stored_name"]).unlink(missing_ok=True) data["projects"] = [ project for project in data["projects"] if project["id"] != project_id ] data["chats"] = [ chat for chat in data["chats"] if chat["project_id"] != project_id ] data["messages"] = [ message for message in data["messages"] if message["chat_id"] not in chat_ids ] data["files"] = [ file for file in data["files"] if file["project_id"] != project_id ] save_db(data) shutil.rmtree( Path(tempfile.gettempdir()) / "helpomatica-agent" / project_id, ignore_errors=True, ) shutil.rmtree(AGENT_DIR / project_id, ignore_errors=True) @app.get("/api/projects/{project_id}/chats") def list_chats(project_id: str, _: dict = Depends(get_current_user)) -> list[dict]: data = load_db() find(data["projects"], project_id, "Проект") chats = [chat for chat in data["chats"] if chat["project_id"] == project_id] chats.sort(key=lambda item: item.get("updated_at") or "", reverse=True) chats.sort(key=lambda item: int(item.get("sort_order", 10**9))) return chats @app.put("/api/projects/{project_id}/chats/order") def reorder_chats( project_id: str, payload: ChatOrderUpdate, user: dict = Depends(get_current_user), ) -> list[dict]: data = load_db() find(data["projects"], project_id, "Проект") project_chats = { chat["id"]: chat for chat in data["chats"] if chat["project_id"] == project_id } if not payload.chat_ids: raise HTTPException(status_code=422, detail="Передайте упорядоченный список чатов") if len(payload.chat_ids) != len(set(payload.chat_ids)): raise HTTPException(status_code=422, detail="Список чатов содержит дубликаты") if set(payload.chat_ids) != set(project_chats): raise HTTPException(status_code=422, detail="Список чатов не совпадает с проектом") timestamp = now() for index, chat_id in enumerate(payload.chat_ids): chat = project_chats[chat_id] chat["sort_order"] = index chat["updated_by"] = user["id"] project = find(data["projects"], project_id, "Проект") project["updated_at"] = timestamp save_db(data) return sorted( [project_chats[chat_id] for chat_id in payload.chat_ids], key=lambda item: item.get("sort_order", 0), ) @app.post("/api/chats", status_code=201) def create_chat(payload: ChatCreate, user: dict = Depends(get_current_user)) -> dict: data = load_db() project = find(data["projects"], payload.project_id, "Проект") timestamp = now() project_orders = [ int(chat.get("sort_order", 0)) for chat in data["chats"] if chat["project_id"] == payload.project_id ] next_order = (min(project_orders) - 1) if project_orders else 0 chat = { "id": uuid.uuid4().hex, "project_id": payload.project_id, "title": payload.title.strip() or "Новый чат", "sort_order": next_order, "created_by": user["id"], "updated_by": user["id"], "created_at": timestamp, "updated_at": timestamp, } data["chats"].append(chat) project["updated_at"] = timestamp save_db(data) return chat @app.patch("/api/chats/{chat_id}") def update_chat( chat_id: str, payload: ChatUpdate, user: dict = Depends(get_current_user), ) -> dict: data = load_db() chat = find(data["chats"], chat_id, "Чат") if payload.title is not None: title = payload.title.strip() if not title: raise HTTPException(status_code=422, detail="Название не может быть пустым") chat["title"] = title chat["updated_by"] = user["id"] chat["updated_at"] = now() save_db(data) return chat @app.delete("/api/chats/{chat_id}", status_code=204) def delete_chat(chat_id: str, _: dict = Depends(get_current_user)) -> None: data = load_db() find(data["chats"], chat_id, "Чат") data["chats"] = [chat for chat in data["chats"] if chat["id"] != chat_id] data["messages"] = [ message for message in data["messages"] if message["chat_id"] != chat_id ] leftover: list[dict] = [] for stored_file in data["files"]: if file_scope_of(stored_file) == "chat" and stored_file.get("chat_id") == chat_id: (UPLOAD_DIR / stored_file["stored_name"]).unlink(missing_ok=True) continue leftover.append(stored_file) data["files"] = leftover save_db(data) @app.get("/api/chats/{chat_id}/messages") def list_messages(chat_id: str, _: dict = Depends(get_current_user)) -> list[dict]: data = load_db() find(data["chats"], chat_id, "Чат") return [ message for message in data["messages"] if message["chat_id"] == chat_id ] @app.get("/api/chats/{chat_id}/export") def export_chat(chat_id: str, _: dict = Depends(get_current_user)) -> Response: data = load_db() chat = find(data["chats"], chat_id, "Чат") project = find(data["projects"], chat["project_id"], "Проект") messages = [message for message in data["messages"] if message["chat_id"] == chat_id] markdown = export_chat_markdown(chat, project, messages) title = re.sub(r'[<>:"/\\|?*]+', "_", str(chat.get("title") or "chat"))[:80] or "chat" return Response( content=markdown.encode("utf-8"), media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": attachment_filename(f"{title}.md", "chat.md")}, ) @app.post("/api/chats/{chat_id}/messages", status_code=201) def create_message( chat_id: str, payload: MessageCreate, user: dict = Depends(get_current_user), ) -> dict: data = load_db() chat = find(data["chats"], chat_id, "Чат") content = payload.content.strip() if not content: raise HTTPException(status_code=422, detail="Сообщение не может быть пустым") timestamp = now() role = payload.role if payload.role in ("user", "assistant") else "user" attachments: list[dict] = [] if role == "user" and payload.attachments: project_files = { entry["id"]: entry for entry in data["files"] if entry.get("project_id") == chat["project_id"] } seen: set[str] = set() for ref in payload.attachments: file_id = (ref.id or "").strip() if not file_id or file_id in seen: continue entry = project_files.get(file_id) if not entry: continue seen.add(file_id) other_chat = entry.get("chat_id") if ( file_scope_of(entry) == "chat" and other_chat and other_chat != chat_id ): continue if file_scope_of(entry) != "chat" or entry.get("chat_id") != chat_id: entry["scope"] = "chat" entry["chat_id"] = chat_id attachments.append({"id": entry["id"], "name": entry["name"]}) message = { "id": uuid.uuid4().hex, "chat_id": chat_id, "role": role, "content": content, "attachments": attachments, "user_id": user["id"] if role == "user" else None, "author_name": ( user.get("display_name") or user.get("username") if role == "user" else "Помощник" ), "created_at": timestamp, } data["messages"].append(message) if chat["title"] == "Новый чат" and role == "user": title_source = re.sub( r"\n\n\[Прикреплённые файлы проекта:\s*[^\]]+\]\s*$", "", content, ).strip() or content chat["title"] = title_source[:55] + ("…" if len(title_source) > 55 else "") chat["updated_at"] = timestamp chat["updated_by"] = user["id"] project = find(data["projects"], chat["project_id"], "Проект") project["updated_at"] = timestamp save_db(data) return message def save_assistant_message( chat_id: str, content: str, thinking: str = "", thinking_duration_ms: int | None = None, activity: list[dict] | None = None, ) -> dict | None: content = (content or "").strip() if not content: return None data = load_db() chat = find(data["chats"], chat_id, "Чат") timestamp = now() thinking_text = (thinking or "").strip() if len(thinking_text) > 80_000: thinking_text = thinking_text[:80_000] + "\n…" message = { "id": uuid.uuid4().hex, "chat_id": chat_id, "role": "assistant", "content": content, "thinking": thinking_text, "thinking_duration_ms": thinking_duration_ms, "activity": _trim_activity(activity or []), "user_id": None, "author_name": "Помощник", "created_at": timestamp, } data["messages"].append(message) chat["updated_at"] = timestamp project = find(data["projects"], chat["project_id"], "Проект") project["updated_at"] = timestamp save_db(data) return message @app.post("/api/chats/{chat_id}/respond") async def generate_response( chat_id: str, request: Request, user: dict = Depends(get_current_user), ) -> StreamingResponse: data = load_db() chat = find(data["chats"], chat_id, "Чат") project = find(data["projects"], chat["project_id"], "Проект") messages = [ message for message in data["messages"] if message["chat_id"] == chat_id ] if not messages or messages[-1]["role"] != "user": raise HTTPException(status_code=409, detail="Сначала отправьте сообщение") slot, turn_event = claim_chat_slot(chat_id) cancel_event = threading.Event() out: queue.Queue = queue.Queue() if slot == "run": register_active_run(chat_id, cancel_event, user["id"]) else: # Queued request is cancellable via its own cancel_event stored lightly. with agent_status_lock: active_runs.setdefault( f"wait:{chat_id}", { "cancel_event": cancel_event, "run": None, "loop": None, "thread": None, "user_id": user["id"], }, ) async def event_stream() -> AsyncIterator[str]: became_runner = slot == "run" try: if slot == "wait": yield sse_format("status", {"state": "queued", "detail": "В очереди…"}) got_turn = await wait_chat_turn(turn_event, cancel_event, request) with agent_status_lock: active_runs.pop(f"wait:{chat_id}", None) if not got_turn: yield sse_format("cancelled", {"text": ""}) return became_runner = True register_active_run(chat_id, cancel_event, user["id"]) # No global agent lock: each chat launches its own local bridge. yield sse_format("status", {"state": "starting", "detail": "Запуск…"}) fresh = load_db() fresh_chat = find(fresh["chats"], chat_id, "Чат") fresh_project = find(fresh["projects"], fresh_chat["project_id"], "Проект") fresh_messages = [ message for message in fresh["messages"] if message["chat_id"] == chat_id ] if not fresh_messages or fresh_messages[-1]["role"] != "user": yield sse_format("error", {"detail": "Сначала отправьте сообщение"}) return deduplicated: list[dict] = [] for message in fresh_messages: if ( deduplicated and deduplicated[-1]["role"] == message["role"] and deduplicated[-1]["content"] == message["content"] ): continue deduplicated.append(message) history = "\n\n".join( f"{'Пользователь' if message['role'] == 'user' else 'Ассистент'}: " f"{message['content']}" for message in deduplicated[-30:] ) query = deduplicated[-1]["content"] attachment_ids = message_attachment_ids(fresh_messages) scoped_files = iter_scoped_files(fresh, fresh_project["id"], chat_id) file_context, context_files = build_file_context( fresh, fresh_project["id"], query, chat_id=chat_id, attachment_ids=attachment_ids, ) model = (fresh_project.get("model") or default_model()).strip() or default_model() workspace = prepare_agent_workspace( fresh_project, fresh, query, chat_id=chat_id, attachment_ids=attachment_ids, messages=fresh_messages, ) chip_groups = build_context_chip_groups( fresh, fresh_project["id"], chat_id, fresh_messages ) yield sse_format( "context", {**chip_groups, "files": context_files, "model": model}, ) log_entries = log_preanalysis.rank_log_files(scoped_files) log_names = [str(entry.get("name") or "") for entry in log_entries] run_preanalysis = bool( log_entries and log_preanalysis.looks_like_log_job(query, log_names) ) cache_hit = lookup_analysis_cache( fresh, fresh_project["id"], scoped_files, query ) preanalysis_md = "" preanalysis_summary = "" pre_activity: list[dict] = [] if run_preanalysis: if cache_hit and cache_hit.get("preanalysis"): preanalysis_md = str(cache_hit.get("preanalysis") or "") preanalysis_summary = str(cache_hit.get("summary") or "") yield sse_format( "progress", { "phase": "cache", "message": "Есть предыдущий разбор", "percent": 100, }, ) pre_step = { "id": "pre-scan", "type": "status", "title": "Логи", "detail": preanalysis_summary or "Кэш предыдущего разбора", "status": "completed", } pre_activity.append(pre_step) yield sse_format("activity", pre_step) else: yield sse_format( "status", {"state": "scanning", "detail": "Читаю лог… 0%"}, ) prog_q: queue.Queue = queue.Queue() def on_progress(info: dict) -> None: prog_q.put(("p", info)) scan_files = [ (resolve_upload_path(entry), log_display_name(entry)) for entry in log_entries ] def run_scan() -> None: try: result = log_preanalysis.preanalyze_logs( scan_files, query, on_progress=on_progress, cancel=cancel_event.is_set, ) prog_q.put(("done", result)) except Exception as error: # noqa: BLE001 logger.exception("preanalysis failed") prog_q.put(("err", format_agent_error(error))) threading.Thread(target=run_scan, daemon=True).start() scan_result: dict | None = None while True: kind, payload = await asyncio.to_thread(prog_q.get) if kind == "p": yield sse_format("progress", payload) detail = str(payload.get("message") or "Читаю лог…") yield sse_format( "status", {"state": "scanning", "detail": detail}, ) step = { "id": "pre-scan", "type": "status", "title": "Логи", "detail": detail, "status": "running", } if not pre_activity: pre_activity.append(step) else: pre_activity[0].update(step) yield sse_format("activity", step) elif kind == "done": scan_result = payload if isinstance(payload, dict) else None break else: logger.warning("preanalysis error: %s", payload) break if scan_result: preanalysis_md = str(scan_result.get("markdown") or "") preanalysis_summary = str(scan_result.get("summary") or "") if pre_activity: pre_activity[0]["status"] = "completed" pre_activity[0]["detail"] = ( preanalysis_summary or pre_activity[0].get("detail") or "Готово" ) yield sse_format("activity", pre_activity[0]) if preanalysis_md: try: log_preanalysis.write_preanalysis_md(workspace, preanalysis_md) except OSError as error: logger.warning("PREANALYSIS.md write failed: %s", error) extra = _prompt_extra_from_cache_and_scan(cache_hit, preanalysis_md) prompt = build_chat_prompt(fresh_project, file_context, history, extra=extra) yield sse_format( "progress", { "phase": "agent", "message": "Думаю…", "percent": None, }, ) yield sse_format("status", {"state": "streaming", "detail": "Думаю…"}) thread = threading.Thread( target=_stream_cursor_agent_threaded, args=(prompt, workspace, model, chat_id, out, cancel_event), daemon=True, ) with agent_status_lock: entry = active_runs.get(chat_id) if entry is not None: entry["thread"] = thread thread.start() final_text = "" thinking_text = "" thinking_ms = None activity_items: list[dict] = list(pre_activity) terminal = None while True: item = await asyncio.to_thread(out.get) if item is None: break event_type = item.get("type", "status") if event_type == "thinking": chunk = item.get("text") or "" thinking_text += chunk yield sse_format("thinking", {"text": chunk, "replace": False}) elif event_type == "activity": payload = item.get("step") or { k: v for k, v in item.items() if k != "type" } existing = next( ( step for step in activity_items if payload.get("id") and step.get("id") == payload.get("id") ), None, ) if existing is not None: existing.update({k: v for k, v in payload.items() if v is not None}) else: activity_items.append(payload) yield sse_format("activity", payload) elif event_type == "thinking_done": duration = item.get("duration_ms") if duration is not None: try: thinking_ms = int(duration) except (TypeError, ValueError): pass yield sse_format("thinking_done", {"duration_ms": thinking_ms}) elif event_type == "delta": chunk = item.get("text") or "" if item.get("replace"): final_text = chunk else: final_text += chunk yield sse_format( "delta", {"text": chunk, "replace": bool(item.get("replace"))}, ) elif event_type == "done": final_text = item.get("text") or final_text thinking_text = item.get("thinking") or thinking_text if item.get("thinking_duration_ms") is not None: thinking_ms = item.get("thinking_duration_ms") if item.get("activity"): activity_items = _trim_activity( list(pre_activity) + (item.get("activity") or []) ) terminal = "done" message = save_assistant_message( chat_id, final_text, thinking=thinking_text, thinking_duration_ms=thinking_ms, activity=activity_items, ) try: store_analysis_cache( fresh_project["id"], scoped_files, query, final_text, preanalysis=preanalysis_md, summary=preanalysis_summary, ) except Exception as cache_error: # noqa: BLE001 logger.warning("analysis cache store failed: %s", cache_error) yield sse_format("done", {"text": final_text, "message": message}) elif event_type == "cancelled": final_text = item.get("text") or final_text thinking_text = item.get("thinking") or thinking_text if item.get("thinking_duration_ms") is not None: thinking_ms = item.get("thinking_duration_ms") if item.get("activity"): activity_items = _trim_activity(item.get("activity") or []) terminal = "cancelled" message = save_assistant_message( chat_id, final_text, thinking=thinking_text, thinking_duration_ms=thinking_ms, activity=activity_items, ) yield sse_format( "cancelled", {"text": final_text, "message": message}, ) elif event_type == "error": terminal = "error" yield sse_format( "error", {"detail": item.get("detail", "Ошибка агента")}, ) else: yield sse_format(event_type, {k: v for k, v in item.items() if k != "type"}) if terminal is None and final_text: message = save_assistant_message( chat_id, final_text, thinking=thinking_text, thinking_duration_ms=thinking_ms, activity=activity_items, ) try: store_analysis_cache( fresh_project["id"], scoped_files, query, final_text, preanalysis=preanalysis_md, summary=preanalysis_summary, ) except Exception as cache_error: # noqa: BLE001 logger.warning("analysis cache store failed: %s", cache_error) yield sse_format("done", {"text": final_text, "message": message}) elif terminal is None and cancel_event.is_set(): yield sse_format("cancelled", {"text": ""}) except HTTPException as error: yield sse_format("error", {"detail": error.detail}) except Exception as error: # noqa: BLE001 logger.exception("respond failed chat=%s", chat_id) yield sse_format("error", {"detail": format_agent_error(error)}) finally: unregister_active_run(chat_id, cancel_event) with agent_status_lock: wait_entry = active_runs.get(f"wait:{chat_id}") if wait_entry and wait_entry.get("cancel_event") is cancel_event: active_runs.pop(f"wait:{chat_id}", None) if became_runner: release_running_slot(chat_id) elif slot == "wait": abandon_waiting_slot(chat_id) return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.post("/api/chats/{chat_id}/cancel") async def cancel_response( chat_id: str, _: dict = Depends(get_current_user), ) -> dict: with agent_status_lock: entry = active_runs.get(chat_id) or active_runs.get(f"wait:{chat_id}") if not entry: raise HTTPException(status_code=404, detail="Нет активной генерации") entry["cancel_event"].set() run = entry.get("run") loop = entry.get("loop") if run is not None and loop is not None: try: fut = asyncio.run_coroutine_threadsafe(run.cancel(), loop) fut.result(timeout=5) except Exception as error: # noqa: BLE001 logger.warning("cancel_run chat=%s: %s", chat_id, error) return {"ok": True, "chat_id": chat_id} @app.get("/api/search") def search_chats(q: str = "", _: dict = Depends(get_current_user)) -> list[dict]: needle = (q or "").strip() if len(needle) < 2: return [] needle_l = needle.lower() data = load_db() projects_by_id = {item["id"]: item for item in data.get("projects") or []} chats_by_id = {item["id"]: item for item in data.get("chats") or []} results: list[dict] = [] def add_result( chat: dict, *, match_in: str, snippet: str, message_id: str = "", ) -> None: if len(results) >= 30: return project = projects_by_id.get(chat.get("project_id") or "") results.append( { "chat_id": chat["id"], "project_id": chat.get("project_id") or "", "project_name": (project or {}).get("name") or "", "chat_title": chat.get("title") or "Чат", "message_id": message_id, "match_in": match_in, "snippet": snippet[:240], } ) for chat in sorted( data.get("chats") or [], key=lambda item: item.get("updated_at") or "", reverse=True, ): title = str(chat.get("title") or "") if needle_l in title.lower(): add_result(chat, match_in="title", snippet=title) if len(results) >= 30: return results for message in reversed(data.get("messages") or []): if len(results) >= 30: break content = str(message.get("content") or "") if needle_l not in content.lower(): continue chat = chats_by_id.get(message.get("chat_id") or "") if not chat: continue add_result( chat, match_in="message", snippet=snippet_around(content, needle), message_id=str(message.get("id") or ""), ) return results[:30] @app.get("/api/projects/{project_id}/files") def list_files( project_id: str, chat_id: str | None = None, _: dict = Depends(get_current_user), ) -> list[dict]: data = load_db() find(data["projects"], project_id, "Проект") bound_chat_id = (chat_id or "").strip() or None if bound_chat_id: chat = find(data["chats"], bound_chat_id, "Чат") if chat["project_id"] != project_id: raise HTTPException(status_code=400, detail="Чат не принадлежит проекту") return iter_scoped_files(data, project_id, bound_chat_id) return [ file for file in data["files"] if file["project_id"] == project_id and file_scope_of(file) == "project" ] @app.post("/api/projects/{project_id}/files", status_code=201) def upload_file( project_id: str, request: Request, file: UploadFile = File(...), label: str = Form(default=""), chat_id: str | None = Form(default=None), file_scope: str | None = Form(default=None, alias="scope"), _: dict = Depends(get_current_user), ) -> dict: data = load_db() project = find(data["projects"], project_id, "Проект") query_chat = (request.query_params.get("chat_id") or "").strip() or None query_scope = (request.query_params.get("scope") or "").strip().lower() bound_chat_id = (chat_id or "").strip() or query_chat requested_scope = ((file_scope or "").strip().lower() or query_scope) if requested_scope == "chat" and not bound_chat_id: raise HTTPException( status_code=400, detail="Для файлов чата нужен chat_id", ) stored_scope = "project" if bound_chat_id: chat = find(data["chats"], bound_chat_id, "Чат") if chat["project_id"] != project_id: raise HTTPException(status_code=400, detail="Чат не принадлежит проекту") stored_scope = "chat" original_name = Path(file.filename or "file").name stored_name = f"{uuid.uuid4().hex}_{original_name}" destination = UPLOAD_DIR / stored_name with destination.open("wb") as output: shutil.copyfileobj(file.file, output) entry = { "id": uuid.uuid4().hex, "project_id": project_id, "chat_id": bound_chat_id, "scope": stored_scope, "name": original_name, "label": label.strip(), "stored_name": stored_name, "content_type": file.content_type or "application/octet-stream", "size": destination.stat().st_size, "created_at": now(), } data["files"].append(entry) project["updated_at"] = now() save_db(data) logger.info( "upload file project=%s scope=%s chat_id=%s name=%s", project_id, stored_scope, bound_chat_id, original_name, ) return entry @app.post("/api/chats/{chat_id}/files/bind") def bind_files_to_chat( chat_id: str, payload: BindFilesPayload, _: dict = Depends(get_current_user), ) -> list[dict]: data = load_db() chat = find(data["chats"], chat_id, "Чат") bound: list[dict] = [] seen: set[str] = set() for file_id in payload.file_ids: fid = (file_id or "").strip() if not fid or fid in seen: continue seen.add(fid) entry = next( ( item for item in data["files"] if item.get("id") == fid and item.get("project_id") == chat["project_id"] ), None, ) if not entry: continue other_chat = entry.get("chat_id") if file_scope_of(entry) == "chat" and other_chat and other_chat != chat_id: continue entry["scope"] = "chat" entry["chat_id"] = chat_id bound.append(entry) chat["updated_at"] = now() save_db(data) return bound @app.get("/api/files/{file_id}") def download_file(file_id: str, _: dict = Depends(get_current_user)) -> FileResponse: data = load_db() stored_file = find(data["files"], file_id, "Файл") if not file_in_user_scope(data, stored_file): raise HTTPException(status_code=404, detail="Файл не найден") path = UPLOAD_DIR / stored_file["stored_name"] if not path.exists(): raise HTTPException(status_code=404, detail="Файл не найден на диске") original_name = stored_file.get("name") or path.name return FileResponse( path, filename=original_name, media_type=stored_file.get("content_type") or "application/octet-stream", content_disposition_type="attachment", ) @app.get("/api/files/{file_id}/excerpt") def download_file_excerpt( file_id: str, start: int | None = Query(default=None, ge=1), end: int | None = Query(default=None, ge=1), line: int | None = Query(default=None, ge=1), window: int = Query(default=25, ge=1, le=200), from_line: int | None = Query(default=None, alias="from", ge=1), to_line: int | None = Query(default=None, alias="to", ge=1), _: dict = Depends(get_current_user), ) -> Response: data = load_db() stored_file = find(data["files"], file_id, "Файл") if not file_in_user_scope(data, stored_file): raise HTTPException(status_code=404, detail="Файл не найден") path = UPLOAD_DIR / stored_file["stored_name"] if not path.exists(): raise HTTPException(status_code=404, detail="Файл не найден на диске") start_line = start or from_line end_line = end or to_line if line is not None: start_line = max(1, line - window) end_line = line + window if start_line is None: start_line = 1 if end_line is None: end_line = start_line + window if end_line < start_line: start_line, end_line = end_line, start_line if end_line - start_line > 500: end_line = start_line + 500 try: text, _encoding = log_preanalysis.excerpt_lines(path, start_line, end_line) except OSError as error: raise HTTPException(status_code=500, detail=f"Не удалось прочитать лог: {error}") from error original = stored_file.get("name") or path.name filename = f"{Path(original).name}.{start_line}-{end_line}.txt" return Response( content=text.encode("utf-8"), media_type="text/plain; charset=utf-8", headers={"Content-Disposition": attachment_filename(filename, "excerpt.txt")}, ) @app.delete("/api/files/{file_id}", status_code=204) def delete_file(file_id: str, _: dict = Depends(get_current_user)) -> None: data = load_db() stored_file = find(data["files"], file_id, "Файл") (UPLOAD_DIR / stored_file["stored_name"]).unlink(missing_ok=True) data["files"] = [file for file in data["files"] if file["id"] != file_id] save_db(data)