Files
Helpomatica/main.py
T

3536 lines
130 KiB
Python
Raw Normal View History

2026-08-14 11:06:58 +07:00
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)