3536 lines
130 KiB
Python
3536 lines
130 KiB
Python
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)
|