1818 lines
79 KiB
Python
1818 lines
79 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import re
|
|
import shutil
|
|
import asyncio
|
|
import html
|
|
import signal
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, Callable, List, Tuple
|
|
from dataclasses import dataclass, field, asdict
|
|
|
|
from config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def escape_html(text: str) -> str:
|
|
"""Safely escapes text for Telegram HTML parse mode."""
|
|
if not text:
|
|
return ""
|
|
return html.escape(str(text), quote=False)
|
|
|
|
# Available models
|
|
AVAILABLE_MODELS = {
|
|
"gemini-3.7-flash-auto": "Gemini 3.7 Flash Auto (پیشفرض هوشمند ⚡/🧠)",
|
|
"gemini-3.7-flash": "Gemini 3.7 Flash",
|
|
"gemini-3.7-flash-high": "Gemini 3.7 Flash (High Reasoning)",
|
|
"gemini-3.6-flash": "Gemini 3.6 Flash",
|
|
"gemini-3.1-pro": "Gemini 3.1 Pro (Deep Architecture)",
|
|
"claude-sonnet-4-6": "Claude Sonnet 4.6 (Thinking)",
|
|
"claude-opus-4-6-thinking": "Claude Opus 4.6 (Thinking)",
|
|
"gpt-oss-120b": "GPT-OSS 120B (Medium)",
|
|
}
|
|
|
|
AVAILABLE_EFFORTS = ["low", "medium", "high"]
|
|
|
|
def get_valid_effort_for_model(model_name: Optional[str], requested_effort: Optional[str]) -> Optional[str]:
|
|
"""Validates and maps reasoning effort to what the specific model actually supports in AGY CLI."""
|
|
if not model_name:
|
|
return None
|
|
|
|
m = model_name.lower()
|
|
|
|
# Auto model starts with low effort (or uses requested if specified)
|
|
if "flash-auto" in m or m == "gemini-3.7-flash-auto":
|
|
if requested_effort and requested_effort.lower() in ("low", "medium", "high"):
|
|
return requested_effort.lower()
|
|
return "low"
|
|
|
|
if not requested_effort:
|
|
return None
|
|
|
|
e = requested_effort.lower()
|
|
|
|
# Claude models do NOT support --effort (they are inherently thinking models)
|
|
if "claude" in m:
|
|
return None
|
|
|
|
# GPT models only support medium
|
|
if "gpt" in m:
|
|
return "medium"
|
|
|
|
# gemini-3.7-flash-high only supports high
|
|
if "flash-high" in m:
|
|
return "high"
|
|
|
|
# gemini-3.1-pro only supports low or high
|
|
if "3.1-pro" in m:
|
|
return "high" if e in ("high", "medium") else "low"
|
|
|
|
# Standard gemini models (3.7-flash, 3.6-flash, 3.5-flash)
|
|
if e in ("low", "medium", "high"):
|
|
return e
|
|
|
|
return None
|
|
|
|
# Available languages
|
|
AVAILABLE_LANGUAGES = {
|
|
"fa": "🇮🇷 Persian / Farsi (فارسی)",
|
|
"en": "🇬🇧 English",
|
|
"auto": "🌐 Auto (Detect)",
|
|
"id": "🇮🇩 Indonesian (Bahasa Indonesia)",
|
|
"es": "🇪🇸 Spanish (Español)",
|
|
"zh": "🇨🇳 Chinese (中文)",
|
|
"ja": "🇯🇵 Japanese (日本語)",
|
|
"de": "🇩🇪 German (Deutsch)",
|
|
"fr": "🇫🇷 French (Français)",
|
|
"ru": "🇷🇺 Russian (Русский)",
|
|
"ar": "🇸🇦 Arabic (العربية)",
|
|
"pt": "🇧🇷 Portuguese (Português)",
|
|
"ko": "🇰🇷 Korean (한국어)",
|
|
"it": "🇮🇹 Italian (Italiano)",
|
|
"nl": "🇳🇱 Dutch (Nederlands)",
|
|
"tr": "🇹🇷 Turkish (Türkçe)",
|
|
"vi": "🇻🇳 Vietnamese (Tiếng Việt)",
|
|
}
|
|
|
|
@dataclass
|
|
class AgentResult:
|
|
text: str
|
|
usage: Optional[Dict[str, Any]] = None
|
|
duration: float = 0.0
|
|
conversation_id: Optional[str] = None
|
|
executed_model: Optional[str] = None
|
|
executed_effort: Optional[str] = None
|
|
|
|
@dataclass
|
|
class Project:
|
|
name: str
|
|
workspace: str = field(default_factory=lambda: settings.default_workspace)
|
|
owner_id: Optional[int] = None
|
|
shared_with: List[int] = field(default_factory=list)
|
|
conversation_id: Optional[str] = None
|
|
conversation_history: List[str] = field(default_factory=list)
|
|
model: str = field(default_factory=lambda: settings.default_model)
|
|
effort: str = field(default_factory=lambda: settings.default_effort)
|
|
language: str = field(default_factory=lambda: settings.default_language)
|
|
last_context_length: Optional[int] = None
|
|
last_total_tokens: Optional[int] = None
|
|
last_response: Optional[str] = None
|
|
created_at: float = field(default_factory=time.time)
|
|
description: str = ""
|
|
conversation_titles: Dict[str, str] = field(default_factory=dict)
|
|
|
|
@dataclass
|
|
class Session:
|
|
chat_id: int
|
|
active_project: Optional[str] = None
|
|
projects: Dict[str, Project] = field(default_factory=dict)
|
|
turn_in_progress: bool = False
|
|
last_prompt: Optional[str] = None
|
|
last_response: Optional[str] = None
|
|
last_status_msg_id: Optional[int] = None
|
|
restart_pending: bool = False
|
|
last_delivered: bool = True
|
|
last_update_time: float = 0.0
|
|
language: str = field(default_factory=lambda: settings.default_language)
|
|
|
|
@property
|
|
def current_project(self) -> Optional[Project]:
|
|
return session_manager.get_current_project(self.chat_id)
|
|
|
|
@current_project.setter
|
|
def current_project(self, val: Any):
|
|
if isinstance(val, Project):
|
|
self.active_project = val.name
|
|
elif isinstance(val, str):
|
|
self.active_project = val
|
|
elif val is None:
|
|
self.active_project = None
|
|
|
|
@property
|
|
def conversation_id(self) -> Optional[str]:
|
|
curr = self.current_project
|
|
return curr.conversation_id if curr else None
|
|
|
|
@conversation_id.setter
|
|
def conversation_id(self, val: Optional[str]):
|
|
curr = self.current_project
|
|
if curr:
|
|
curr.conversation_id = val
|
|
|
|
@property
|
|
def workspace(self) -> str:
|
|
curr = self.current_project
|
|
return curr.workspace if curr else settings.default_workspace
|
|
|
|
@workspace.setter
|
|
def workspace(self, val: str):
|
|
curr = self.current_project
|
|
if curr:
|
|
curr.workspace = val
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
curr = self.current_project
|
|
return curr.model if curr else settings.default_model
|
|
|
|
@model.setter
|
|
def model(self, val: str):
|
|
curr = self.current_project
|
|
if curr:
|
|
curr.model = val
|
|
|
|
@property
|
|
def effort(self) -> str:
|
|
curr = self.current_project
|
|
return curr.effort if curr else settings.default_effort
|
|
|
|
@effort.setter
|
|
def effort(self, val: str):
|
|
curr = self.current_project
|
|
if curr:
|
|
curr.effort = val
|
|
|
|
@property
|
|
def last_context_length(self) -> Optional[int]:
|
|
curr = self.current_project
|
|
return curr.last_context_length if curr else None
|
|
|
|
@last_context_length.setter
|
|
def last_context_length(self, val: Optional[int]):
|
|
curr = self.current_project
|
|
if curr:
|
|
curr.last_context_length = val
|
|
|
|
@property
|
|
def last_total_tokens(self) -> Optional[int]:
|
|
curr = self.current_project
|
|
return curr.last_total_tokens if curr else None
|
|
|
|
@last_total_tokens.setter
|
|
def last_total_tokens(self, val: Optional[int]):
|
|
curr = self.current_project
|
|
if curr:
|
|
curr.last_total_tokens = val
|
|
|
|
class SessionManager:
|
|
def __init__(self, storage_file: Path = Path("/root/telegram-agy-bot/sessions.json")):
|
|
self.storage_file = storage_file
|
|
self.sessions: Dict[int, Session] = {}
|
|
self.active_tasks: Dict[int, asyncio.Task] = {}
|
|
self.active_procs: Dict[int, asyncio.subprocess.Process] = {}
|
|
self._load()
|
|
|
|
def _load(self):
|
|
if self.storage_file.exists():
|
|
try:
|
|
with open(self.storage_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
for k, v in data.items():
|
|
if not isinstance(v, dict):
|
|
continue
|
|
uid = int(k)
|
|
is_admin_user = settings.is_admin(uid)
|
|
projects_dict: Dict[str, Project] = {}
|
|
if "projects" in v and isinstance(v["projects"], dict):
|
|
for p_name, p_data in v["projects"].items():
|
|
if isinstance(p_data, dict):
|
|
clean_p = {
|
|
k2: v2
|
|
for k2, v2 in p_data.items()
|
|
if k2 in (
|
|
"name",
|
|
"workspace",
|
|
"owner_id",
|
|
"shared_with",
|
|
"conversation_id",
|
|
"conversation_history",
|
|
"model",
|
|
"effort",
|
|
"language",
|
|
"last_context_length",
|
|
"last_total_tokens",
|
|
"last_response",
|
|
"created_at",
|
|
"description",
|
|
"conversation_titles",
|
|
)
|
|
}
|
|
if "name" not in clean_p:
|
|
clean_p["name"] = p_name
|
|
if "owner_id" not in clean_p or clean_p["owner_id"] is None:
|
|
clean_p["owner_id"] = uid
|
|
if "shared_with" not in clean_p or not isinstance(clean_p["shared_with"], list):
|
|
clean_p["shared_with"] = []
|
|
if "conversation_history" not in clean_p or not isinstance(clean_p["conversation_history"], list):
|
|
clean_p["conversation_history"] = []
|
|
if clean_p.get("conversation_id") and clean_p["conversation_id"] not in clean_p["conversation_history"]:
|
|
clean_p["conversation_history"].append(clean_p["conversation_id"])
|
|
if "conversation_titles" not in clean_p or not isinstance(clean_p["conversation_titles"], dict):
|
|
clean_p["conversation_titles"] = {}
|
|
|
|
# Non-admin users cannot own or access the "default" /root project
|
|
if not is_admin_user and (p_name == "default" or clean_p.get("workspace") == settings.default_workspace):
|
|
continue
|
|
|
|
projects_dict[p_name] = Project(**clean_p)
|
|
|
|
# For admin users, if no projects exist, initialize "default" project
|
|
if is_admin_user and "default" not in projects_dict and not projects_dict:
|
|
default_ws = v.get("workspace", settings.default_workspace)
|
|
init_cid = v.get("conversation_id")
|
|
projects_dict["default"] = Project(
|
|
name="default",
|
|
workspace=default_ws,
|
|
owner_id=uid,
|
|
shared_with=[],
|
|
conversation_id=init_cid,
|
|
conversation_history=[init_cid] if init_cid else [],
|
|
model=v.get("model", settings.default_model),
|
|
effort=v.get("effort", settings.default_effort),
|
|
language=v.get("language", settings.default_language),
|
|
last_context_length=v.get("last_context_length"),
|
|
last_total_tokens=v.get("last_total_tokens"),
|
|
last_response=v.get("last_response"),
|
|
)
|
|
|
|
active_p = v.get("active_project")
|
|
if not is_admin_user and active_p == "default":
|
|
active_p = None
|
|
|
|
if active_p and active_p not in projects_dict:
|
|
active_p = list(projects_dict.keys())[0] if projects_dict else None
|
|
elif not active_p and projects_dict:
|
|
active_p = list(projects_dict.keys())[0]
|
|
|
|
session = Session(
|
|
chat_id=uid,
|
|
active_project=active_p,
|
|
projects=projects_dict,
|
|
turn_in_progress=v.get("turn_in_progress", False),
|
|
last_prompt=v.get("last_prompt"),
|
|
last_response=v.get("last_response"),
|
|
last_status_msg_id=v.get("last_status_msg_id"),
|
|
restart_pending=v.get("restart_pending", False),
|
|
last_delivered=v.get("last_delivered", True),
|
|
last_update_time=v.get("last_update_time", 0.0),
|
|
language=v.get("language", settings.default_language),
|
|
)
|
|
self.sessions[uid] = session
|
|
except Exception as e:
|
|
logger.error(f"Error loading sessions: {e}")
|
|
|
|
def save(self):
|
|
try:
|
|
data = {}
|
|
for k, sess in self.sessions.items():
|
|
projects_serialized = {}
|
|
for p_name, p_obj in sess.projects.items():
|
|
projects_serialized[p_name] = {
|
|
"name": p_obj.name,
|
|
"workspace": p_obj.workspace,
|
|
"owner_id": p_obj.owner_id,
|
|
"shared_with": p_obj.shared_with,
|
|
"conversation_id": p_obj.conversation_id,
|
|
"conversation_history": p_obj.conversation_history,
|
|
"model": p_obj.model,
|
|
"effort": p_obj.effort,
|
|
"language": p_obj.language,
|
|
"last_context_length": p_obj.last_context_length,
|
|
"last_total_tokens": p_obj.last_total_tokens,
|
|
"last_response": p_obj.last_response,
|
|
"created_at": p_obj.created_at,
|
|
"description": p_obj.description,
|
|
"conversation_titles": p_obj.conversation_titles,
|
|
}
|
|
curr = self.get_current_project(sess.chat_id)
|
|
data[str(k)] = {
|
|
"chat_id": sess.chat_id,
|
|
"active_project": sess.active_project,
|
|
"projects": projects_serialized,
|
|
"turn_in_progress": sess.turn_in_progress,
|
|
"last_prompt": sess.last_prompt,
|
|
"last_response": sess.last_response,
|
|
"last_status_msg_id": sess.last_status_msg_id,
|
|
"restart_pending": sess.restart_pending,
|
|
"last_delivered": sess.last_delivered,
|
|
"last_update_time": sess.last_update_time,
|
|
# Top-level compatibility
|
|
"workspace": curr.workspace if curr else settings.default_workspace,
|
|
"conversation_id": curr.conversation_id if curr else None,
|
|
"model": curr.model if curr else settings.default_model,
|
|
"effort": curr.effort if curr else settings.default_effort,
|
|
"language": curr.language if curr else sess.language,
|
|
"last_context_length": curr.last_context_length if curr else None,
|
|
"last_total_tokens": curr.last_total_tokens if curr else None,
|
|
}
|
|
tmp_file = f"{self.storage_file}.tmp.{os.getpid()}_{int(time.time()*1000)}"
|
|
with open(tmp_file, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2)
|
|
os.replace(tmp_file, self.storage_file)
|
|
except Exception as e:
|
|
logger.error(f"Error saving sessions: {e}")
|
|
|
|
def get_or_create(self, chat_id: int) -> Session:
|
|
is_admin_user = settings.is_admin(chat_id)
|
|
if chat_id not in self.sessions:
|
|
if is_admin_user:
|
|
default_proj = Project(
|
|
name="default",
|
|
workspace=settings.default_workspace,
|
|
owner_id=chat_id,
|
|
shared_with=[],
|
|
model=settings.default_model,
|
|
effort=settings.default_effort,
|
|
language=settings.default_language,
|
|
)
|
|
sess = Session(
|
|
chat_id=chat_id,
|
|
active_project="default",
|
|
projects={"default": default_proj},
|
|
language=settings.default_language,
|
|
)
|
|
else:
|
|
sess = Session(
|
|
chat_id=chat_id,
|
|
active_project=None,
|
|
projects={},
|
|
language=settings.default_language,
|
|
)
|
|
self.sessions[chat_id] = sess
|
|
self.save()
|
|
else:
|
|
sess = self.sessions[chat_id]
|
|
if not is_admin_user:
|
|
if "default" in sess.projects and sess.projects["default"].workspace == settings.default_workspace:
|
|
del sess.projects["default"]
|
|
if sess.active_project == "default":
|
|
acc = self.get_all_accessible_projects(chat_id)
|
|
sess.active_project = list(acc.keys())[0] if acc else None
|
|
self.save()
|
|
elif is_admin_user and not sess.projects:
|
|
sess.projects["default"] = Project(
|
|
name="default",
|
|
workspace=settings.default_workspace,
|
|
owner_id=chat_id,
|
|
shared_with=[],
|
|
model=settings.default_model,
|
|
effort=settings.default_effort,
|
|
language=settings.default_language,
|
|
)
|
|
if not sess.active_project:
|
|
sess.active_project = "default"
|
|
self.save()
|
|
|
|
return self.sessions[chat_id]
|
|
|
|
def sanitize_project_name(self, name: str) -> str:
|
|
clean = re.sub(r"[^\w\-]", "-", name.strip().lower())
|
|
clean = re.sub(r"-+", "-", clean).strip("-")
|
|
return clean or "project"
|
|
|
|
def get_user_projects(self, chat_id: int) -> Dict[str, Project]:
|
|
"""Returns all projects created and owned by chat_id."""
|
|
sess = self.sessions.get(chat_id)
|
|
if not sess:
|
|
return {}
|
|
is_admin_user = settings.is_admin(chat_id)
|
|
result = {}
|
|
for name, p in sess.projects.items():
|
|
if name == "default" and not is_admin_user:
|
|
continue
|
|
result[name] = p
|
|
return result
|
|
|
|
def get_shared_projects(self, chat_id: int) -> Dict[str, Project]:
|
|
"""Returns all projects owned by other users that are shared with chat_id."""
|
|
result = {}
|
|
for other_id, other_sess in self.sessions.items():
|
|
if other_id == chat_id:
|
|
continue
|
|
for p_name, p in other_sess.projects.items():
|
|
if chat_id in p.shared_with:
|
|
result[p_name] = p
|
|
return result
|
|
|
|
def get_all_accessible_projects(self, chat_id: int) -> Dict[str, Project]:
|
|
"""Returns all projects accessible to chat_id (owned, shared, and default for admin)."""
|
|
own = self.get_user_projects(chat_id)
|
|
shared = self.get_shared_projects(chat_id)
|
|
combined = dict(own)
|
|
for k, v in shared.items():
|
|
if k not in combined:
|
|
combined[k] = v
|
|
else:
|
|
combined[f"{k} (shared:{v.owner_id})"] = v
|
|
return combined
|
|
|
|
def get_current_project(self, chat_id: int) -> Optional[Project]:
|
|
"""Returns the currently active Project object for chat_id, or None if no project is active."""
|
|
if chat_id not in self.sessions:
|
|
self.get_or_create(chat_id)
|
|
sess = self.sessions[chat_id]
|
|
accessible = self.get_all_accessible_projects(chat_id)
|
|
if not accessible:
|
|
sess.active_project = None
|
|
return None
|
|
|
|
if sess.active_project and sess.active_project in accessible:
|
|
return accessible[sess.active_project]
|
|
|
|
if sess.active_project:
|
|
for k, p in accessible.items():
|
|
if k.lower() == sess.active_project.lower() or p.name.lower() == sess.active_project.lower():
|
|
sess.active_project = k
|
|
self.save()
|
|
return p
|
|
|
|
# Fallback to first accessible project
|
|
first_key = list(accessible.keys())[0]
|
|
sess.active_project = first_key
|
|
self.save()
|
|
return accessible[first_key]
|
|
|
|
async def create_project(
|
|
self,
|
|
chat_id: int,
|
|
name: str,
|
|
workspace: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
effort: Optional[str] = None,
|
|
language: Optional[str] = None,
|
|
description: str = "",
|
|
) -> Project:
|
|
session = self.get_or_create(chat_id)
|
|
proj_name = self.sanitize_project_name(name)
|
|
is_admin_user = settings.is_admin(chat_id)
|
|
|
|
if not is_admin_user and proj_name == "default":
|
|
proj_name = "my-project"
|
|
|
|
# Workspace directory: /root/projects/{chat_id}/{proj_name}
|
|
if not is_admin_user or not workspace:
|
|
ws_path = str(Path("/root/projects") / str(chat_id) / proj_name)
|
|
else:
|
|
ws_path = str(Path(workspace).expanduser().resolve())
|
|
|
|
os.makedirs(ws_path, exist_ok=True)
|
|
|
|
curr = self.get_current_project(chat_id)
|
|
curr_lang = curr.language if curr else (session.language or settings.default_language)
|
|
|
|
proj = Project(
|
|
name=proj_name,
|
|
workspace=ws_path,
|
|
owner_id=chat_id,
|
|
shared_with=[],
|
|
conversation_id=None,
|
|
model=model or settings.default_model,
|
|
effort=effort or settings.default_effort,
|
|
language=language or curr_lang,
|
|
created_at=time.time(),
|
|
description=description,
|
|
)
|
|
session.projects[proj_name] = proj
|
|
session.active_project = proj_name
|
|
self.save()
|
|
|
|
# Initialize Git repository and link with Gitea
|
|
try:
|
|
from git_manager import git_manager
|
|
await git_manager.init_project_repo(ws_path, proj_name)
|
|
except Exception as ge:
|
|
logger.warning(f"Failed to auto-init git repository for {proj_name}: {ge}")
|
|
|
|
return proj
|
|
|
|
async def switch_project(self, chat_id: int, name: str) -> Optional[Project]:
|
|
session = self.get_or_create(chat_id)
|
|
clean_target = name.strip()
|
|
accessible = self.get_all_accessible_projects(chat_id)
|
|
|
|
if clean_target in accessible:
|
|
self.cancel_active_task(chat_id)
|
|
session.active_project = clean_target
|
|
self.save()
|
|
return accessible[clean_target]
|
|
|
|
for k, p in accessible.items():
|
|
if k.lower() == clean_target.lower() or p.name.lower() == clean_target.lower():
|
|
self.cancel_active_task(chat_id)
|
|
session.active_project = k
|
|
self.save()
|
|
return p
|
|
|
|
return None
|
|
|
|
async def delete_project(self, chat_id: int, name: str, delete_files: bool = True) -> tuple[bool, str]:
|
|
session = self.get_or_create(chat_id)
|
|
is_admin_user = settings.is_admin(chat_id)
|
|
proj_name = name.strip()
|
|
|
|
matched_key = None
|
|
for k in session.projects.keys():
|
|
if k.lower() == proj_name.lower():
|
|
matched_key = k
|
|
break
|
|
|
|
if not matched_key:
|
|
shared = self.get_shared_projects(chat_id)
|
|
for k, p in shared.items():
|
|
if k.lower() == proj_name.lower() or p.name.lower() == proj_name.lower():
|
|
return False, "❌ شما فقط عضو مشترک این پروژه هستید و امکان حذف آن را ندارید."
|
|
return False, f"❌ پروژه <code>{proj_name}</code> یافت نشد."
|
|
|
|
if matched_key == "default" and not is_admin_user:
|
|
return False, "❌ امکان حذف پروژه سیستم وجود ندارد."
|
|
|
|
self.cancel_active_task(chat_id)
|
|
proj_obj = session.projects.pop(matched_key)
|
|
cleaned_details = []
|
|
|
|
# 1. Terminate running background processes / open ports in workspace
|
|
if proj_obj.workspace and proj_obj.workspace != "/root" and os.path.isdir(proj_obj.workspace):
|
|
try:
|
|
os.system(f"fuser -k -9 {proj_obj.workspace} > /dev/null 2>&1")
|
|
cleaned_details.append("🛑 فرآیندها و پورتهای اجرایی پروژه متوقف شدند.")
|
|
except Exception as pe:
|
|
logger.debug(f"Process cleanup warning: {pe}")
|
|
|
|
# 2. Stop and disable any systemd service if created for this project
|
|
service_file = Path(f"/etc/systemd/system/{matched_key}.service")
|
|
if service_file.exists():
|
|
try:
|
|
os.system(f"systemctl stop {matched_key} && systemctl disable {matched_key} > /dev/null 2>&1")
|
|
service_file.unlink(missing_ok=True)
|
|
os.system("systemctl daemon-reload > /dev/null 2>&1")
|
|
cleaned_details.append(f"⚙️ سرویس سیستمی <code>{matched_key}.service</code> متوقف و حذف شد.")
|
|
except Exception as se:
|
|
logger.warning(f"Failed to remove systemd service: {se}")
|
|
|
|
# 3. Release and remove Caddy reverse proxy & subdomain if configured
|
|
try:
|
|
from bot_actions import remove_caddy_reverse_proxy
|
|
caddy_ok, caddy_msg = remove_caddy_reverse_proxy(matched_key)
|
|
if caddy_ok:
|
|
cleaned_details.append(f"🌐 سابدامین و پراکسی Caddy (<code>{matched_key}.msa.artacloud.ir</code>) آزاد و حذف شد.")
|
|
except Exception as ce:
|
|
logger.debug(f"Caddy cleanup check: {ce}")
|
|
|
|
# 4. Cancel all scheduled tasks / cron jobs associated with this project
|
|
try:
|
|
from scheduler import task_scheduler
|
|
removed_tasks = 0
|
|
for t_id, task in list(task_scheduler.tasks.items()):
|
|
if task.project_name.lower() == matched_key.lower() or (proj_obj.workspace and proj_obj.workspace in task.content):
|
|
await task_scheduler.delete_task(t_id)
|
|
removed_tasks += 1
|
|
if removed_tasks > 0:
|
|
cleaned_details.append(f"⏰ تعداد {removed_tasks} تسک زمانبندیشده مربوط به پروژه لغو گردید.")
|
|
except Exception as te:
|
|
logger.debug(f"Tasks cleanup check: {te}")
|
|
|
|
# 5. Delete Gitea remote repository
|
|
try:
|
|
from git_manager import git_manager
|
|
g_ok, g_msg = await git_manager.delete_gitea_repo(matched_key)
|
|
if g_ok:
|
|
cleaned_details.append("🐙 مخزن اختصاصی در سرور گیت (Gitea) حذف شد.")
|
|
except Exception as ge:
|
|
logger.warning(f"Failed to delete Gitea repo {matched_key}: {ge}")
|
|
|
|
# 6. Delete physical directory if requested and not /root
|
|
if delete_files and proj_obj.workspace and proj_obj.workspace != "/root" and os.path.isdir(proj_obj.workspace):
|
|
try:
|
|
shutil.rmtree(proj_obj.workspace, ignore_errors=True)
|
|
cleaned_details.append(f"🗑️ پوشه فایلها (<code>{proj_obj.workspace}</code>) پاکسازی شد.")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to remove project dir {proj_obj.workspace}: {e}")
|
|
|
|
accessible = self.get_all_accessible_projects(chat_id)
|
|
if session.active_project == matched_key:
|
|
session.active_project = list(accessible.keys())[0] if accessible else None
|
|
|
|
self.save()
|
|
|
|
details_str = "\n".join([f"• {item}" for item in cleaned_details])
|
|
out_msg = (
|
|
f"✅ <b>پروژه <code>{matched_key}</code> با موفقیت به صورت کامل و هوشمند حذف گردید:</b>\n\n"
|
|
f"{details_str}"
|
|
)
|
|
return True, out_msg
|
|
|
|
async def rename_project(self, chat_id: int, old_name: str, new_name: str) -> tuple[Optional[Project], str]:
|
|
session = self.get_or_create(chat_id)
|
|
is_admin_user = settings.is_admin(chat_id)
|
|
old_matched = None
|
|
for k in session.projects.keys():
|
|
if k.lower() == old_name.strip().lower():
|
|
old_matched = k
|
|
break
|
|
|
|
if not old_matched:
|
|
shared = self.get_shared_projects(chat_id)
|
|
for k, p in shared.items():
|
|
if k.lower() == old_name.strip().lower() or p.name.lower() == old_name.strip().lower():
|
|
return None, "❌ شما دسترسی تغییر نام این پروژه اشتراکی را ندارید."
|
|
return None, f"❌ پروژه <code>{old_name}</code> یافت نشد."
|
|
|
|
if old_matched == "default" and not is_admin_user:
|
|
return None, "❌ امکان تغییر نام پروژه سیستم وجود ندارد."
|
|
|
|
clean_new = self.sanitize_project_name(new_name)
|
|
if clean_new in session.projects and clean_new != old_matched:
|
|
return None, f"❌ پروژهای با نام <code>{clean_new}</code> قبلاً وجود دارد."
|
|
|
|
proj = session.projects.pop(old_matched)
|
|
proj.name = clean_new
|
|
session.projects[clean_new] = proj
|
|
if session.active_project == old_matched:
|
|
session.active_project = clean_new
|
|
|
|
self.save()
|
|
return proj, f"✅ نام پروژه با موفقیت به <code>{clean_new}</code> تغییر یافت."
|
|
|
|
async def share_project(
|
|
self,
|
|
owner_chat_id: int,
|
|
target_user_id: int,
|
|
project_name: Optional[str] = None,
|
|
) -> tuple[bool, str, Optional[Project]]:
|
|
is_admin_user = settings.is_admin(owner_chat_id)
|
|
session = self.get_or_create(owner_chat_id)
|
|
|
|
if project_name:
|
|
proj = None
|
|
for k, p in session.projects.items():
|
|
if k.lower() == project_name.strip().lower() or p.name.lower() == project_name.strip().lower():
|
|
proj = p
|
|
break
|
|
if not proj and is_admin_user:
|
|
accessible = self.get_all_accessible_projects(owner_chat_id)
|
|
for k, p in accessible.items():
|
|
if k.lower() == project_name.strip().lower() or p.name.lower() == project_name.strip().lower():
|
|
proj = p
|
|
break
|
|
else:
|
|
proj = self.get_current_project(owner_chat_id)
|
|
|
|
if not proj:
|
|
return False, "❌ پروژهای برای اشتراکگذاری پیدا نشد.", None
|
|
|
|
if proj.name == "default" and not is_admin_user:
|
|
return False, "❌ امکان اشتراکگذاری پروژه پیشفرض سیستم وجود ندارد.", None
|
|
|
|
if proj.owner_id and proj.owner_id != owner_chat_id and not is_admin_user:
|
|
return False, "❌ فقط سازنده اصلی (مالک) پروژه میتواند آن را به اشتراک بگذارد.", None
|
|
|
|
if target_user_id == owner_chat_id:
|
|
return False, "❌ شما نمیتوانید پروژه را با شناسه خودتان به اشتراک بگذارید.", None
|
|
|
|
if target_user_id in proj.shared_with:
|
|
return True, f"ℹ️ این پروژه قبلاً با کاربر <code>{target_user_id}</code> به اشتراک گذاشته شده بود.", proj
|
|
|
|
proj.shared_with.append(target_user_id)
|
|
self.save()
|
|
return True, f"🎉 پروژه <b>{proj.name}</b> با موفقیت با کاربر <code>{target_user_id}</code> به اشتراک گذاشته شد.", proj
|
|
|
|
async def unshare_project(
|
|
self,
|
|
owner_chat_id: int,
|
|
target_user_id: int,
|
|
project_name: Optional[str] = None,
|
|
) -> tuple[bool, str, Optional[Project]]:
|
|
is_admin_user = settings.is_admin(owner_chat_id)
|
|
session = self.get_or_create(owner_chat_id)
|
|
|
|
if project_name:
|
|
proj = None
|
|
for k, p in session.projects.items():
|
|
if k.lower() == project_name.strip().lower() or p.name.lower() == project_name.strip().lower():
|
|
proj = p
|
|
break
|
|
if not proj and is_admin_user:
|
|
accessible = self.get_all_accessible_projects(owner_chat_id)
|
|
for k, p in accessible.items():
|
|
if k.lower() == project_name.strip().lower() or p.name.lower() == project_name.strip().lower():
|
|
proj = p
|
|
break
|
|
else:
|
|
proj = self.get_current_project(owner_chat_id)
|
|
|
|
if not proj:
|
|
return False, "❌ پروژهای پیدا نشد.", None
|
|
|
|
if proj.owner_id and proj.owner_id != owner_chat_id and not is_admin_user:
|
|
return False, "❌ فقط مالک پروژه میتواند دسترسی کاربران را لغو کند.", None
|
|
|
|
if target_user_id not in proj.shared_with:
|
|
return False, f"❌ کاربر <code>{target_user_id}</code> دسترسی به این پروژه نداشت.", None
|
|
|
|
proj.shared_with.remove(target_user_id)
|
|
|
|
if target_user_id in self.sessions:
|
|
target_sess = self.sessions[target_user_id]
|
|
if target_sess.active_project == proj.name:
|
|
target_acc = self.get_all_accessible_projects(target_user_id)
|
|
target_sess.active_project = list(target_acc.keys())[0] if target_acc else None
|
|
|
|
self.save()
|
|
return True, f"✅ دسترسی کاربر <code>{target_user_id}</code> به پروژه <b>{proj.name}</b> لغو گردید.", proj
|
|
|
|
async def reset_session(self, chat_id: int, project_name: Optional[str] = None) -> Optional[Session]:
|
|
self.cancel_active_task(chat_id)
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if curr:
|
|
if curr.conversation_id and curr.conversation_id not in curr.conversation_history:
|
|
curr.conversation_history.append(curr.conversation_id)
|
|
curr.conversation_id = None
|
|
curr.last_context_length = None
|
|
curr.last_total_tokens = None
|
|
curr.last_response = None
|
|
session.turn_in_progress = False
|
|
session.last_prompt = None
|
|
session.last_response = None
|
|
self.save()
|
|
return session
|
|
|
|
def reopen_last_conversation(self, chat_id: int) -> tuple[bool, str, Optional[str], Dict[str, Any]]:
|
|
"""
|
|
Reopens the last/previous conversation for the active project.
|
|
Returns (success, message, conversation_id, metadata).
|
|
"""
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if not curr:
|
|
return False, "⚠️ پروژهای یافت نشد.", None, {}
|
|
|
|
target_conv_id = None
|
|
valid_history = [cid for cid in curr.conversation_history if cid]
|
|
|
|
if not curr.conversation_id:
|
|
if valid_history:
|
|
target_conv_id = valid_history[-1]
|
|
else:
|
|
if curr.conversation_id not in valid_history:
|
|
valid_history.append(curr.conversation_id)
|
|
curr.conversation_history = valid_history
|
|
idx = valid_history.index(curr.conversation_id)
|
|
if idx > 0:
|
|
target_conv_id = valid_history[idx - 1]
|
|
elif len(valid_history) > 1:
|
|
target_conv_id = valid_history[-1]
|
|
else:
|
|
target_conv_id = curr.conversation_id
|
|
|
|
# If still no target_conv_id, discover from disk
|
|
if not target_conv_id:
|
|
all_convs = self.get_project_conversations(chat_id)
|
|
if all_convs:
|
|
for c in all_convs:
|
|
if c["id"] != curr.conversation_id:
|
|
target_conv_id = c["id"]
|
|
break
|
|
if not target_conv_id:
|
|
target_conv_id = all_convs[0]["id"]
|
|
|
|
if not target_conv_id:
|
|
return False, "⚠️ هیچ گفتگوی قبلی برای این پروژه یافت نشد.", None, {}
|
|
|
|
curr.conversation_id = target_conv_id
|
|
if target_conv_id not in curr.conversation_history:
|
|
curr.conversation_history.append(target_conv_id)
|
|
|
|
meta = get_conversation_metadata(target_conv_id)
|
|
output_data = get_conversation_last_output(target_conv_id)
|
|
curr.last_response = output_data.get("text") or meta.get("last_response")
|
|
|
|
self.save()
|
|
return True, f"✅ گفتگوی <code>{target_conv_id[:8]}...</code> با موفقیت باز و فعال شد.", target_conv_id, meta
|
|
|
|
def switch_conversation(self, chat_id: int, conv_id: str) -> tuple[bool, str, Dict[str, Any]]:
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
clean_id = str(conv_id).strip()
|
|
matched_cid = None
|
|
target_proj = curr
|
|
|
|
# 1. First check if it's a numeric index or ID in current project's conversation list
|
|
if curr:
|
|
convs = self.get_project_conversations(chat_id)
|
|
if clean_id.isdigit():
|
|
idx = int(clean_id) - 1
|
|
if 0 <= idx < len(convs):
|
|
matched_cid = convs[idx]["id"]
|
|
|
|
if not matched_cid:
|
|
for c in convs:
|
|
if c["id"] == clean_id or c["id"].lower().startswith(clean_id.lower()):
|
|
matched_cid = c["id"]
|
|
break
|
|
|
|
if not matched_cid:
|
|
for c_id in curr.conversation_history:
|
|
if c_id == clean_id or (c_id and c_id.lower().startswith(clean_id.lower())):
|
|
matched_cid = c_id
|
|
break
|
|
|
|
# 2. If not found in current project, search across all accessible projects of this user
|
|
if not matched_cid:
|
|
accessible = self.get_all_accessible_projects(chat_id)
|
|
for p_name, p_obj in accessible.items():
|
|
all_p_cids = list(p_obj.conversation_history)
|
|
if p_obj.conversation_id and p_obj.conversation_id not in all_p_cids:
|
|
all_p_cids.append(p_obj.conversation_id)
|
|
|
|
for c_id in all_p_cids:
|
|
if c_id == clean_id or (c_id and c_id.lower().startswith(clean_id.lower())):
|
|
matched_cid = c_id
|
|
target_proj = p_obj
|
|
break
|
|
if matched_cid:
|
|
break
|
|
|
|
# 3. Fallback: check if valid conversation exists on disk
|
|
if not matched_cid:
|
|
meta = get_conversation_metadata(clean_id)
|
|
if meta.get("file_exists"):
|
|
matched_cid = clean_id
|
|
target_proj = curr
|
|
|
|
if not matched_cid or not target_proj:
|
|
return False, f"⚠️ گفتگویی با شناسه یا شماره «{clean_id}» یافت نشد.", {}
|
|
|
|
# If switching to a conversation belonging to another project, automatically switch active project
|
|
project_switched = False
|
|
if session.active_project != target_proj.name:
|
|
session.active_project = target_proj.name
|
|
project_switched = True
|
|
|
|
meta = get_conversation_metadata(matched_cid)
|
|
|
|
if target_proj.conversation_id and target_proj.conversation_id != matched_cid and target_proj.conversation_id not in target_proj.conversation_history:
|
|
target_proj.conversation_history.append(target_proj.conversation_id)
|
|
|
|
target_proj.conversation_id = matched_cid
|
|
if matched_cid not in target_proj.conversation_history:
|
|
target_proj.conversation_history.append(matched_cid)
|
|
|
|
output_data = get_conversation_last_output(matched_cid)
|
|
target_proj.last_response = output_data.get("text") or meta.get("last_response")
|
|
self.save()
|
|
|
|
if project_switched:
|
|
msg = f"✅ با موفقیت به گفتگوی <code>{matched_cid[:8]}...</code> در پروژه <b>{escape_html(target_proj.name)}</b> سوییچ کردید."
|
|
else:
|
|
msg = f"✅ با موفقیت به گفتگوی <code>{matched_cid[:8]}...</code> سوییچ کردید."
|
|
|
|
return True, msg, meta
|
|
|
|
def delete_conversation(self, chat_id: int, conv_id: str) -> tuple[bool, str]:
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if not curr:
|
|
return False, "⚠️ پروژهای یافت نشد."
|
|
|
|
clean_id = str(conv_id).strip()
|
|
convs = self.get_project_conversations(chat_id)
|
|
matched_cid = None
|
|
|
|
if clean_id.isdigit():
|
|
idx = int(clean_id) - 1
|
|
if 0 <= idx < len(convs):
|
|
matched_cid = convs[idx]["id"]
|
|
|
|
if not matched_cid:
|
|
for c in convs:
|
|
if c["id"] == clean_id or c["id"].lower().startswith(clean_id.lower()):
|
|
matched_cid = c["id"]
|
|
break
|
|
|
|
if not matched_cid:
|
|
for c_id in curr.conversation_history:
|
|
if c_id == clean_id or (c_id and c_id.lower().startswith(clean_id.lower())):
|
|
matched_cid = c_id
|
|
break
|
|
|
|
if not matched_cid:
|
|
meta = get_conversation_metadata(clean_id)
|
|
if meta.get("file_exists"):
|
|
matched_cid = clean_id
|
|
|
|
if not matched_cid:
|
|
return False, f"⚠️ گفتگویی با شناسه یا شماره «{clean_id}» یافت نشد."
|
|
|
|
# Remove from conversation_history
|
|
curr.conversation_history = [cid for cid in curr.conversation_history if cid != matched_cid]
|
|
|
|
# If it was the active conversation
|
|
was_active = (curr.conversation_id == matched_cid)
|
|
if was_active:
|
|
if curr.conversation_history:
|
|
curr.conversation_id = curr.conversation_history[-1]
|
|
output_data = get_conversation_last_output(curr.conversation_id)
|
|
meta = get_conversation_metadata(curr.conversation_id)
|
|
curr.last_response = output_data.get("text") or meta.get("last_response")
|
|
else:
|
|
curr.conversation_id = None
|
|
curr.last_response = None
|
|
curr.last_context_length = None
|
|
curr.last_total_tokens = None
|
|
|
|
# Clean brain folder on disk
|
|
brain_path = Path("/root/.gemini/antigravity-cli/brain") / matched_cid
|
|
if brain_path.exists() and brain_path.is_dir():
|
|
try:
|
|
shutil.rmtree(brain_path, ignore_errors=True)
|
|
except Exception as e:
|
|
logger.error(f"Error removing brain dir {brain_path}: {e}")
|
|
|
|
if hasattr(curr, "conversation_titles") and matched_cid in curr.conversation_titles:
|
|
curr.conversation_titles.pop(matched_cid, None)
|
|
|
|
self.save()
|
|
return True, f"✅ گفتگوی <code>{matched_cid[:8]}...</code> با موفقیت حذف شد."
|
|
|
|
def clear_project_conversations(self, chat_id: int) -> tuple[bool, str, int]:
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if not curr:
|
|
return False, "⚠️ پروژهای یافت نشد.", 0
|
|
|
|
convs = self.get_project_conversations(chat_id)
|
|
all_ids = set(curr.conversation_history)
|
|
if curr.conversation_id:
|
|
all_ids.add(curr.conversation_id)
|
|
for c in convs:
|
|
all_ids.add(c["id"])
|
|
|
|
if not all_ids:
|
|
return False, "⚠️ هیچ گفتگویی در این پروژه برای حذف وجود ندارد.", 0
|
|
|
|
count = 0
|
|
for cid in all_ids:
|
|
brain_path = Path("/root/.gemini/antigravity-cli/brain") / cid
|
|
if brain_path.exists() and brain_path.is_dir():
|
|
try:
|
|
shutil.rmtree(brain_path, ignore_errors=True)
|
|
except Exception as e:
|
|
logger.error(f"Error removing brain dir {brain_path}: {e}")
|
|
count += 1
|
|
|
|
curr.conversation_history = []
|
|
curr.conversation_id = None
|
|
if hasattr(curr, "conversation_titles"):
|
|
curr.conversation_titles = {}
|
|
curr.last_response = None
|
|
curr.last_context_length = None
|
|
curr.last_total_tokens = None
|
|
session.last_prompt = None
|
|
session.last_response = None
|
|
|
|
self.save()
|
|
return True, f"✅ تمام {count} گفتگوی پروژه با موفقیت حذف و پاکسازی شدند.", count
|
|
|
|
def set_conversation_title(self, chat_id: int, title: str, conv_id: Optional[str] = None, project_name: Optional[str] = None) -> tuple[bool, str, str]:
|
|
"""
|
|
Sets a custom title / topic for the specified conversation (or active conversation).
|
|
Returns (success, message, matched_conv_id).
|
|
"""
|
|
session = self.get_or_create(chat_id)
|
|
target_proj = None
|
|
if project_name:
|
|
accessible = self.get_all_accessible_projects(chat_id)
|
|
for k, p in accessible.items():
|
|
if k.lower() == project_name.lower() or p.name.lower() == project_name.lower():
|
|
target_proj = p
|
|
break
|
|
if not target_proj:
|
|
target_proj = self.get_current_project(chat_id)
|
|
|
|
if not target_proj:
|
|
return False, "⚠️ پروژهای یافت نشد.", ""
|
|
|
|
clean_title = (title or "").strip()
|
|
if not clean_title:
|
|
return False, "⚠️ عنوان مشخص نشده است.", ""
|
|
|
|
target_cid = conv_id.strip() if conv_id else (target_proj.conversation_id or "")
|
|
|
|
# If user passed a number like "1", "#1", or partial ID
|
|
if target_cid and (target_cid.startswith("#") or target_cid.isdigit() or len(target_cid) < 15):
|
|
convs = self.get_project_conversations(chat_id)
|
|
clean_num = target_cid.lstrip("#")
|
|
if clean_num.isdigit():
|
|
idx = int(clean_num)
|
|
if 1 <= idx <= len(convs):
|
|
target_cid = convs[idx - 1]["id"]
|
|
else:
|
|
for c in convs:
|
|
if c["id"].lower().startswith(target_cid.lower()):
|
|
target_cid = c["id"]
|
|
break
|
|
|
|
if not target_cid:
|
|
return False, "⚠️ گفتگوی فعالی برای تغییر عنوان یافت نشد.", ""
|
|
|
|
if not hasattr(target_proj, "conversation_titles") or not isinstance(target_proj.conversation_titles, dict):
|
|
target_proj.conversation_titles = {}
|
|
|
|
target_proj.conversation_titles[target_cid] = clean_title
|
|
self.save()
|
|
return True, f"✅ عنوان گفتگو به «<b>{escape_html(clean_title)}</b>» تغییر یافت.", target_cid
|
|
|
|
def get_project_conversations(self, chat_id: int) -> List[Dict[str, Any]]:
|
|
"""
|
|
Returns sorted list of conversation summaries for active project (newest first).
|
|
"""
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if not curr:
|
|
return []
|
|
|
|
conv_ids = list(curr.conversation_history)
|
|
if curr.conversation_id and curr.conversation_id not in conv_ids:
|
|
conv_ids.append(curr.conversation_id)
|
|
|
|
result = []
|
|
titles_map = getattr(curr, "conversation_titles", {}) or {}
|
|
for cid in conv_ids:
|
|
meta = get_conversation_metadata(cid)
|
|
if not meta.get("file_exists") and cid not in curr.conversation_history:
|
|
continue
|
|
is_current = (cid == curr.conversation_id)
|
|
custom_title = titles_map.get(cid)
|
|
result.append({
|
|
"id": cid,
|
|
"is_current": is_current,
|
|
"title": custom_title,
|
|
"first_prompt": meta.get("first_prompt") or "(بدون متن)",
|
|
"last_prompt": meta.get("last_prompt") or "",
|
|
"turns_count": meta.get("turns_count", 0),
|
|
"created_at": meta.get("created_at"),
|
|
"last_updated": meta.get("last_updated") or 0.0,
|
|
"last_response": meta.get("last_response") or "",
|
|
})
|
|
|
|
# Sort by last_updated descending (newest first)
|
|
result.sort(key=lambda x: x.get("last_updated") or 0.0, reverse=True)
|
|
return result
|
|
|
|
|
|
async def set_model(self, chat_id: int, model: str):
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if curr:
|
|
curr.model = model
|
|
self.save()
|
|
|
|
async def set_effort(self, chat_id: int, effort: str):
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if curr:
|
|
curr.effort = effort
|
|
self.save()
|
|
|
|
async def set_workspace(self, chat_id: int, workspace: str):
|
|
session = self.get_or_create(chat_id)
|
|
curr = self.get_current_project(chat_id)
|
|
if curr:
|
|
resolved = str(Path(workspace).expanduser().resolve())
|
|
curr.workspace = resolved
|
|
self.save()
|
|
|
|
async def set_language(self, chat_id: int, language: str):
|
|
session = self.get_or_create(chat_id)
|
|
session.language = language
|
|
curr = self.get_current_project(chat_id)
|
|
if curr:
|
|
curr.language = language
|
|
self.save()
|
|
|
|
def cancel_active_task(self, chat_id: int) -> bool:
|
|
cancelled = False
|
|
if chat_id in self.active_procs:
|
|
proc = self.active_procs.pop(chat_id, None)
|
|
if proc:
|
|
try:
|
|
if hasattr(os, "killpg") and proc.pid:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except Exception:
|
|
pass
|
|
proc.kill()
|
|
except Exception as e:
|
|
logger.debug(f"Error terminating proc for chat_id {chat_id}: {e}")
|
|
cancelled = True
|
|
|
|
if chat_id in self.active_tasks:
|
|
task = self.active_tasks.pop(chat_id, None)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
cancelled = True
|
|
|
|
session = self.sessions.get(chat_id)
|
|
if session:
|
|
if session.turn_in_progress:
|
|
cancelled = True
|
|
session.turn_in_progress = False
|
|
self.save()
|
|
|
|
return cancelled
|
|
|
|
session_manager = SessionManager()
|
|
|
|
ANSI_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
ESCALATE_REGEX = re.compile(
|
|
r"\[\[ESCALATE_EFFORT:\s*effort=[\"'](low|medium|high)[\"'](?:,\s*reason=[\"'](.*?)[\"'])?\s*\]\]",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
def strip_ansi(text: str) -> str:
|
|
return ANSI_REGEX.sub("", text)
|
|
|
|
def clean_user_prompt(raw_text: Optional[str]) -> str:
|
|
"""
|
|
Cleans raw prompt or transcript user input, stripping all internal system instructions,
|
|
prompts metadata, memory blocks, and file markers to extract the actual user message.
|
|
"""
|
|
if not raw_text:
|
|
return ""
|
|
text = str(raw_text)
|
|
|
|
# 1. Extract USER_REQUEST content if present
|
|
req_match = re.search(r"<USER_REQUEST>(.*?)</USER_REQUEST>", text, re.DOTALL)
|
|
if req_match:
|
|
text = req_match.group(1)
|
|
|
|
# 2. Remove standard outer XML/HTML metadata tags
|
|
text = re.sub(r"<ADDITIONAL_METADATA>.*?</ADDITIONAL_METADATA>", "", text, flags=re.DOTALL)
|
|
text = re.sub(r"<USER_SETTINGS_CHANGE>.*?</USER_SETTINGS_CHANGE>", "", text, flags=re.DOTALL)
|
|
text = re.sub(r"<SYSTEM_MESSAGE>.*?</SYSTEM_MESSAGE>", "", text, flags=re.DOTALL)
|
|
text = re.sub(r"<[^>]+>", "", text)
|
|
|
|
# 3. Remove delimited system instruction blocks
|
|
text = re.sub(r"<!--\s*SYSTEM_INSTRUCTIONS_START\s*-->.*?<!--\s*SYSTEM_INSTRUCTIONS_END\s*-->", "", text, flags=re.DOTALL)
|
|
text = re.sub(r"\[SYSTEM_INSTRUCTIONS_BLOCK\].*?\[/SYSTEM_INSTRUCTIONS_BLOCK\]", "", text, flags=re.DOTALL)
|
|
|
|
# 4. Remove known structured system instructions (backward compatibility)
|
|
# Language instruction
|
|
text = re.sub(r"\[SYSTEM INSTRUCTION:\s*You MUST respond and communicate in [^\]]+\]", "", text, flags=re.DOTALL)
|
|
|
|
# Flash auto reasoning mode block
|
|
text = re.sub(r"\[SYSTEM INSTRUCTION:\s*GEMINI 3\.7 FLASH AUTO REASONING MODE\].*?(?:• آدرس مخزن:[^\n]*|\Z)", "", text, flags=re.DOTALL)
|
|
|
|
# Bot actions block
|
|
text = re.sub(r"\[SYSTEM INSTRUCTION:\s*TELEGRAM BOT ACTIONS & FUNCTION CALLING\].*?(?:Always explain to the user in a friendly and professional tone what actions have been performed\.?|\Z)", "", text, flags=re.DOTALL)
|
|
|
|
# Memory instructions block
|
|
text = re.sub(r"\[SYSTEM INSTRUCTION:\s*AI PERSISTENT HIERARCHICAL MEMORY & CONTINUOUS LEARNING\].*?(?:5\. Do NOT store temporary or trivial small-talk\. Only store enduring and valuable knowledge\.?|\Z)", "", text, flags=re.DOTALL)
|
|
|
|
# General fallback for any remaining [SYSTEM INSTRUCTION: ...]
|
|
text = re.sub(r"\[SYSTEM INSTRUCTION:[^\]]+\]", "", text)
|
|
text = re.sub(r"(?:^|\n)System Instruction:\s*", "", text)
|
|
|
|
# Clean uploaded file notifications
|
|
caption_m = re.search(r"\[User uploaded [^\]]+\]\s*(?:Caption:\s*(.*))?", text, re.DOTALL)
|
|
if caption_m:
|
|
cap = (caption_m.group(1) or "").strip()
|
|
if cap:
|
|
text = f"📎 {cap}"
|
|
else:
|
|
text = "📎 [ارسال فایل]"
|
|
|
|
# Clean ANSI codes
|
|
text = ANSI_REGEX.sub("", text)
|
|
|
|
# Collapse multi-lines to clean single-line text
|
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
return " ".join(lines).strip()
|
|
|
|
def get_conversation_metadata(conversation_id: str) -> Dict[str, Any]:
|
|
"""
|
|
Extracts summary, first/last prompt, timestamp, turns count, and tokens for conversation_id.
|
|
"""
|
|
brain_dir = Path("/root/.gemini/antigravity-cli/brain") / conversation_id / ".system_generated/logs"
|
|
file_path = brain_dir / "transcript_full.jsonl"
|
|
if not file_path.exists():
|
|
file_path = brain_dir / "transcript.jsonl"
|
|
|
|
res = {
|
|
"id": conversation_id,
|
|
"first_prompt": "",
|
|
"last_prompt": "",
|
|
"created_at": None,
|
|
"last_updated": 0.0,
|
|
"turns_count": 0,
|
|
"last_response": "",
|
|
"file_exists": False,
|
|
}
|
|
|
|
if not file_path.exists():
|
|
return res
|
|
|
|
res["file_exists"] = True
|
|
try:
|
|
res["last_updated"] = file_path.stat().st_mtime
|
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
lines = [json.loads(line) for line in f if line.strip()]
|
|
|
|
user_inputs = []
|
|
planner_responses = []
|
|
for step in lines:
|
|
if step.get("type") == "USER_INPUT":
|
|
user_inputs.append(step)
|
|
elif step.get("source") == "MODEL" and step.get("type") == "PLANNER_RESPONSE":
|
|
planner_responses.append(step)
|
|
|
|
res["turns_count"] = len(user_inputs)
|
|
if user_inputs:
|
|
res["created_at"] = user_inputs[0].get("created_at")
|
|
first_raw = user_inputs[0].get("content", "")
|
|
first_cleaned = clean_user_prompt(first_raw)
|
|
if not first_cleaned and len(user_inputs) > 1:
|
|
for u in user_inputs[1:]:
|
|
nxt_clean = clean_user_prompt(u.get("content", ""))
|
|
if nxt_clean:
|
|
first_cleaned = nxt_clean
|
|
break
|
|
res["first_prompt"] = first_cleaned or "(بدون متن)"
|
|
last_raw = user_inputs[-1].get("content", "")
|
|
res["last_prompt"] = clean_user_prompt(last_raw)
|
|
|
|
if planner_responses:
|
|
last_resp = planner_responses[-1]
|
|
res["last_response"] = (last_resp.get("content") or "").strip()
|
|
except Exception as e:
|
|
logger.error(f"Error reading metadata for {conversation_id}: {e}")
|
|
|
|
return res
|
|
|
|
def get_conversation_context_tokens(conversation_id: Optional[str]) -> int:
|
|
"""Calculates the true active context tokens of the conversation transcript."""
|
|
if not conversation_id:
|
|
return 0
|
|
brain_dir = Path("/root/.gemini/antigravity-cli/brain") / conversation_id / ".system_generated/logs"
|
|
file_path = brain_dir / "transcript.jsonl"
|
|
if not file_path.exists():
|
|
file_path = brain_dir / "transcript_full.jsonl"
|
|
if not file_path.exists():
|
|
return 0
|
|
try:
|
|
total_chars = 0
|
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
if line.strip():
|
|
step = json.loads(line)
|
|
content = str(step.get("content", ""))
|
|
th = str(step.get("thinking", ""))
|
|
total_chars += len(content) + len(th)
|
|
# 1 token is approximately 3.8 characters for code/text/Persian
|
|
return max(500, int(total_chars / 3.8))
|
|
except Exception as e:
|
|
logger.debug(f"Error calculating context tokens for {conversation_id}: {e}")
|
|
return 0
|
|
|
|
def get_conversation_last_output(conversation_id: str) -> Dict[str, Any]:
|
|
"""
|
|
Reads the AGY transcript for conversation_id from /root/.gemini/antigravity-cli/brain/<conversation_id>/.system_generated/logs/
|
|
Returns a dict with:
|
|
- 'text': final response text (if any)
|
|
- 'tools': list of tools executed in the last turn
|
|
- 'thoughts': list of thoughts in the last turn
|
|
- 'last_user_input': the last user prompt recorded
|
|
"""
|
|
if not conversation_id:
|
|
return {"text": None, "tools": [], "thoughts": [], "last_user_input": None}
|
|
|
|
brain_dir = Path("/root/.gemini/antigravity-cli/brain") / conversation_id / ".system_generated/logs"
|
|
file_path = brain_dir / "transcript_full.jsonl"
|
|
if not file_path.exists():
|
|
file_path = brain_dir / "transcript.jsonl"
|
|
if not file_path.exists():
|
|
return {"text": None, "tools": [], "thoughts": [], "last_user_input": None}
|
|
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
lines = [json.loads(line) for line in f if line.strip()]
|
|
except Exception as e:
|
|
logger.error(f"Error reading transcript for {conversation_id}: {e}")
|
|
return {"text": None, "tools": [], "thoughts": [], "last_user_input": None}
|
|
|
|
if not lines:
|
|
return {"text": None, "tools": [], "thoughts": [], "last_user_input": None}
|
|
|
|
last_user_idx = -1
|
|
last_user_input = None
|
|
for i, step in enumerate(lines):
|
|
if step.get("type") == "USER_INPUT":
|
|
last_user_idx = i
|
|
last_user_input = step.get("content")
|
|
|
|
turn_steps = lines[last_user_idx+1:] if last_user_idx != -1 else lines
|
|
|
|
texts = []
|
|
tools = []
|
|
thoughts = []
|
|
|
|
for step in turn_steps:
|
|
if step.get("source") == "MODEL" and step.get("type") == "PLANNER_RESPONSE":
|
|
content = step.get("content")
|
|
if content and content.strip():
|
|
texts.append(content.strip())
|
|
th = step.get("thinking")
|
|
if th and th.strip():
|
|
thoughts.append(th.strip())
|
|
for tc in step.get("tool_calls", []):
|
|
tname = tc.get("name", "tool")
|
|
targs = tc.get("args", {})
|
|
action = ""
|
|
if isinstance(targs, dict):
|
|
action = targs.get("toolAction") or targs.get("toolSummary") or targs.get("Description") or targs.get("CommandLine") or ""
|
|
elif isinstance(targs, str):
|
|
action = targs
|
|
desc = f"{tname}: {action}" if action else tname
|
|
tools.append(desc)
|
|
|
|
final_text = texts[-1] if texts else None
|
|
return {
|
|
"text": final_text,
|
|
"all_texts": texts,
|
|
"tools": tools,
|
|
"thoughts": thoughts,
|
|
"last_user_input": last_user_input,
|
|
}
|
|
|
|
class AGYEngine:
|
|
@classmethod
|
|
async def _execute_single_run(
|
|
cls,
|
|
session: Session,
|
|
prompt: str,
|
|
target_model: str,
|
|
target_effort: Optional[str],
|
|
is_auto_eval: bool = False,
|
|
on_delta: Optional[Callable[[str], Any]] = None,
|
|
on_thought: Optional[Callable[[str], Any]] = None,
|
|
on_tool: Optional[Callable[[str, str], Any]] = None,
|
|
) -> Tuple[Optional[AgentResult], Optional[str], Optional[str]]:
|
|
"""
|
|
Executes a single AGY CLI invocation.
|
|
If is_auto_eval is True and the model decides to escalate effort,
|
|
it terminates early and returns (None, target_effort, reason).
|
|
Otherwise returns (AgentResult, None, None).
|
|
"""
|
|
workspace_path = str(Path(session.workspace).expanduser().resolve())
|
|
os.makedirs(workspace_path, exist_ok=True)
|
|
|
|
effective_prompt = prompt
|
|
system_instructions = []
|
|
|
|
lang = (session.language or "").strip()
|
|
if lang and lang.lower() not in ("auto", "none", "detect", "auto-detect"):
|
|
lang_label = AVAILABLE_LANGUAGES.get(lang.lower(), lang)
|
|
clean_lang = re.sub(r"[^\w\s\(\)/,\u0600-\u06FF\u0750-\u077F\uFB50-\uFDFF\uFE70-\uFEFF]", "", lang_label).strip()
|
|
if not clean_lang:
|
|
clean_lang = lang
|
|
system_instructions.append(
|
|
f"[SYSTEM INSTRUCTION: You MUST respond and communicate in {clean_lang}. "
|
|
f"All answers, greetings, comments, and explanations must be strictly written in {clean_lang}, "
|
|
f"unless code syntax or the user explicitly specifies another language.]"
|
|
)
|
|
|
|
if is_auto_eval:
|
|
system_instructions.append(
|
|
"[SYSTEM INSTRUCTION: GEMINI 3.7 FLASH AUTO REASONING MODE]\n"
|
|
"You are operating in 'Gemini 3.7 Flash Auto' intelligent reasoning mode (started with MEDIUM reasoning effort by default for high quality initial evaluation).\n"
|
|
"DECISION & LIFECYCLE RULE:\n"
|
|
"1. If this task is simple, straightforward, a quick chat, brief query, or minor edit, output the following tag at the VERY BEGINNING of your response to switch to Low effort:\n"
|
|
"[[ESCALATE_EFFORT: effort=\"low\", reason=\"<brief reason in 1 sentence>\"]]\n"
|
|
"2. If this task is highly complex, requires deep architectural reasoning, multi-file code refactoring, complex bug analysis, or advanced multi-step planning, output:\n"
|
|
"[[ESCALATE_EFFORT: effort=\"high\", reason=\"<brief reason in 1 sentence>\"]]\n"
|
|
"When changing effort, do not output any full solution yet, because the engine will immediately intercept this tag, terminate the initial run, and automatically re-run your prompt with the chosen reasoning effort.\n"
|
|
"3. If the task is moderate and well-suited for Medium effort, proceed directly and complete it.\n"
|
|
"4. Final Report Footer Requirement:\n"
|
|
" At the end of every final completion report, ALWAYS append the following footer:\n"
|
|
" • نام پروژه: <project_name>\n"
|
|
" • سابدامین فعال: https://<subdomain>.msa.artacloud.ir (ONLY include this line if a subdomain is configured/active)\n"
|
|
" • آدرس مخزن: https://git.msa.artacloud.ir/root/<project_name>"
|
|
)
|
|
|
|
# AI Full Bot Actions & Function Calling Capability Instruction
|
|
try:
|
|
from bot_actions import get_ai_bot_actions_instruction
|
|
bot_actions_instruction = get_ai_bot_actions_instruction(lang=clean_lang if 'clean_lang' in locals() else "fa")
|
|
system_instructions.append(bot_actions_instruction)
|
|
except Exception as ex:
|
|
logger.warning(f"Failed to load bot actions instruction: {ex}")
|
|
|
|
# AI Long-Term Persistent Hierarchical Memory (Global + User + Active Project)
|
|
try:
|
|
from memory_manager import memory_manager
|
|
active_p_name = session.active_project or (session.current_project.name if session.current_project else None)
|
|
memory_instruction = memory_manager.format_memories_for_prompt(
|
|
user_id=session.chat_id,
|
|
project_name=active_p_name,
|
|
)
|
|
if memory_instruction:
|
|
system_instructions.append(memory_instruction)
|
|
except Exception as mex:
|
|
logger.warning(f"Failed to load memory instructions for chat {session.chat_id}: {mex}")
|
|
|
|
|
|
if system_instructions:
|
|
effective_prompt = (
|
|
"<!-- SYSTEM_INSTRUCTIONS_START -->\n"
|
|
+ "\n\n".join(system_instructions)
|
|
+ "\n<!-- SYSTEM_INSTRUCTIONS_END -->\n\n"
|
|
+ prompt
|
|
)
|
|
|
|
# Real CLI model name to pass to AGY CLI
|
|
cli_model = "gemini-3.7-flash" if target_model in ("gemini-3.7-flash-auto", "gemini-3.7-flash") else target_model
|
|
|
|
cmd = [
|
|
"agy",
|
|
"-p", effective_prompt,
|
|
"--output-format", "stream-json",
|
|
"--dangerously-skip-permissions",
|
|
]
|
|
|
|
if session.conversation_id:
|
|
cmd.extend(["--conversation", session.conversation_id])
|
|
|
|
if cli_model:
|
|
cmd.extend(["--model", cli_model])
|
|
|
|
valid_effort = get_valid_effort_for_model(cli_model, target_effort)
|
|
if valid_effort:
|
|
cmd.extend(["--effort", valid_effort])
|
|
|
|
if workspace_path:
|
|
cmd.extend(["--add-dir", workspace_path])
|
|
|
|
logger.info(f"Executing AGY command for chat {session.chat_id} (model={cli_model}, effort={valid_effort}, auto_eval={is_auto_eval}): {' '.join(cmd[:4])}...")
|
|
|
|
# Auto-pull external updates from Gitea if enabled
|
|
if settings.gitea_auto_sync and os.path.exists(os.path.join(workspace_path, ".git")):
|
|
try:
|
|
from git_manager import git_manager
|
|
await git_manager.git_pull(workspace_path)
|
|
except Exception as pe:
|
|
logger.debug(f"Pre-prompt git pull skipped: {pe}")
|
|
|
|
start_time = time.time()
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
limit=100 * 1024 * 1024, # 100 MB buffer limit to handle large stream-json chunks
|
|
cwd=workspace_path,
|
|
start_new_session=True,
|
|
)
|
|
|
|
session_manager.active_procs[session.chat_id] = proc
|
|
|
|
collected_tokens: List[str] = []
|
|
final_response: Optional[str] = None
|
|
last_usage: Optional[Dict[str, Any]] = None
|
|
duration: float = 0.0
|
|
escalation_triggered = False
|
|
escalate_effort: Optional[str] = None
|
|
escalate_reason: Optional[str] = None
|
|
|
|
try:
|
|
while True:
|
|
line = await proc.stdout.readline()
|
|
if not line:
|
|
break
|
|
line_str = line.decode("utf-8", errors="replace").strip()
|
|
if not line_str:
|
|
continue
|
|
|
|
try:
|
|
event_data = json.loads(line_str)
|
|
event_type = event_data.get("event")
|
|
|
|
if event_type == "init":
|
|
conv_id = event_data.get("conversation_id")
|
|
if conv_id:
|
|
session.conversation_id = conv_id
|
|
curr = session.current_project
|
|
if curr and conv_id not in curr.conversation_history:
|
|
curr.conversation_history.append(conv_id)
|
|
session_manager.save()
|
|
|
|
elif event_type == "step_update":
|
|
step = event_data.get("step_update", {})
|
|
step_type = step.get("step_type")
|
|
state = step.get("state")
|
|
step_usage = step.get("usage")
|
|
if step_usage:
|
|
last_usage = step_usage
|
|
|
|
if step_type == "agent_response":
|
|
delta = step.get("text_delta")
|
|
if delta:
|
|
collected_tokens.append(delta)
|
|
current_stream = "".join(collected_tokens)
|
|
|
|
if is_auto_eval and not escalation_triggered:
|
|
esc_m = ESCALATE_REGEX.search(current_stream)
|
|
if esc_m:
|
|
escalation_triggered = True
|
|
escalate_effort = esc_m.group(1).lower()
|
|
escalate_reason = (esc_m.group(2) or "").strip()
|
|
logger.info(f"Auto-escalation detected during streaming: {escalate_effort} ({escalate_reason})")
|
|
try:
|
|
proc.terminate()
|
|
except Exception:
|
|
pass
|
|
break
|
|
|
|
if on_delta and not escalation_triggered:
|
|
on_delta(delta)
|
|
|
|
elif step_type == "tool" and state == "ACTIVE":
|
|
tool_name = step.get("tool_name", "tool")
|
|
tool_info = step.get("tool_info", {})
|
|
params = tool_info.get("parameters", {})
|
|
if on_tool and not escalation_triggered:
|
|
on_tool(tool_name, json.dumps(params, indent=2))
|
|
|
|
elif event_type == "result":
|
|
result_info = event_data.get("result", {})
|
|
conv_id = result_info.get("conversation_id")
|
|
if conv_id:
|
|
session.conversation_id = conv_id
|
|
curr = session.current_project
|
|
if curr and conv_id not in curr.conversation_history:
|
|
curr.conversation_history.append(conv_id)
|
|
session_manager.save()
|
|
res_text = result_info.get("response")
|
|
if res_text:
|
|
final_response = res_text
|
|
result_usage = result_info.get("usage")
|
|
if result_usage:
|
|
last_usage = result_usage
|
|
dur = result_info.get("duration_seconds")
|
|
if dur:
|
|
duration = float(dur)
|
|
|
|
except json.JSONDecodeError:
|
|
logger.debug(f"AGY stdout: {line_str}")
|
|
|
|
await proc.wait()
|
|
|
|
if is_auto_eval and not escalation_triggered:
|
|
full_text = final_response or "".join(collected_tokens)
|
|
esc_m = ESCALATE_REGEX.search(full_text)
|
|
if esc_m:
|
|
escalation_triggered = True
|
|
escalate_effort = esc_m.group(1).lower()
|
|
escalate_reason = (esc_m.group(2) or "").strip()
|
|
logger.info(f"Auto-escalation detected in final result: {escalate_effort} ({escalate_reason})")
|
|
|
|
if escalation_triggered:
|
|
return None, escalate_effort, escalate_reason
|
|
|
|
if proc.returncode != 0 and proc.returncode != -15: # -15 is SIGTERM
|
|
stderr_data = (await proc.stderr.read()).decode("utf-8", errors="replace")
|
|
if stderr_data and not collected_tokens and not final_response:
|
|
raise RuntimeError(f"AGY exited with code {proc.returncode}: {stderr_data.strip()}")
|
|
|
|
finally:
|
|
if not duration:
|
|
duration = time.time() - start_time
|
|
|
|
real_tokens = get_conversation_context_tokens(session.conversation_id)
|
|
if real_tokens > 0:
|
|
session.last_context_length = real_tokens
|
|
curr_p = session.current_project
|
|
if curr_p:
|
|
curr_p.last_context_length = real_tokens
|
|
elif last_usage and "input_tokens" in last_usage:
|
|
session.last_context_length = min(last_usage.get("input_tokens", 0), 1_000_000)
|
|
curr_p = session.current_project
|
|
if curr_p:
|
|
curr_p.last_context_length = session.last_context_length
|
|
|
|
if last_usage and "total_tokens" in last_usage:
|
|
session.last_total_tokens = last_usage.get("total_tokens")
|
|
curr_p = session.current_project
|
|
if curr_p:
|
|
curr_p.last_total_tokens = session.last_total_tokens
|
|
|
|
if (final_response or collected_tokens) and not escalation_triggered:
|
|
session.last_response = final_response if final_response else "".join(collected_tokens)
|
|
if session.chat_id in session_manager.active_procs:
|
|
del session_manager.active_procs[session.chat_id]
|
|
if proc and proc.returncode is None:
|
|
try:
|
|
if hasattr(os, "killpg") and proc.pid:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except Exception:
|
|
pass
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
session_manager.save()
|
|
|
|
# Auto-commit and push changes to Gitea if enabled
|
|
if not escalation_triggered and settings.gitea_auto_commit and os.path.exists(os.path.join(workspace_path, ".git")):
|
|
try:
|
|
from git_manager import git_manager
|
|
prompt_summary = clean_user_prompt(prompt)[:60].strip()
|
|
commit_msg = f"AI Update: {prompt_summary}" if prompt_summary else "Updates from Antigravity"
|
|
asyncio.create_task(git_manager.git_commit_and_push(workspace_path, message=commit_msg))
|
|
except Exception as ce:
|
|
logger.debug(f"Post-prompt git commit skipped: {ce}")
|
|
|
|
text_out = final_response if final_response else "".join(collected_tokens)
|
|
if not text_out.strip() and session.conversation_id:
|
|
try:
|
|
rec = get_conversation_last_output(session.conversation_id)
|
|
if rec.get("text"):
|
|
text_out = rec["text"]
|
|
elif rec.get("tools"):
|
|
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
tool_bullets = "\n".join(f"• <code>{t}</code>" for t in rec["tools"][-10:])
|
|
if is_fa:
|
|
text_out = f"✅ **دستورات و ابزارهای زیر با موفقیت اجرا شدند:**\n\n{tool_bullets}"
|
|
else:
|
|
text_out = f"✅ **The following tools and operations executed successfully:**\n\n{tool_bullets}"
|
|
except Exception as ex:
|
|
logger.debug(f"Fallback transcript lookup failed: {ex}")
|
|
|
|
return AgentResult(
|
|
text=text_out,
|
|
usage=last_usage,
|
|
duration=duration,
|
|
conversation_id=session.conversation_id,
|
|
executed_model=target_model,
|
|
executed_effort=valid_effort or target_effort,
|
|
), None, None
|
|
|
|
@classmethod
|
|
async def run_prompt(
|
|
cls,
|
|
session: Session,
|
|
prompt: str,
|
|
on_delta: Optional[Callable[[str], Any]] = None,
|
|
on_thought: Optional[Callable[[str], Any]] = None,
|
|
on_tool: Optional[Callable[[str, str], Any]] = None,
|
|
on_model_change: Optional[Callable[[str, Optional[str]], Any]] = None,
|
|
on_reset: Optional[Callable[[], Any]] = None,
|
|
) -> AgentResult:
|
|
"""Runs prompt using the natively authenticated AGY CLI engine with real-time streaming and Auto-Effort escalation."""
|
|
is_auto_mode = (session.model == "gemini-3.7-flash-auto")
|
|
|
|
if is_auto_mode:
|
|
if on_model_change:
|
|
on_model_change("gemini-3.7-flash-auto", "medium")
|
|
|
|
# First attempt in Auto mode: Start with Medium effort by default
|
|
result, esc_effort, esc_reason = await cls._execute_single_run(
|
|
session=session,
|
|
prompt=prompt,
|
|
target_model="gemini-3.7-flash",
|
|
target_effort="medium",
|
|
is_auto_eval=True,
|
|
on_delta=on_delta,
|
|
on_thought=on_thought,
|
|
on_tool=on_tool,
|
|
)
|
|
|
|
if esc_effort:
|
|
target_eff = esc_effort if esc_effort in ("low", "medium", "high") else "medium"
|
|
logger.info(f"Auto-adjusting effort for chat {session.chat_id} to {target_eff} (reason: {esc_reason})")
|
|
|
|
if on_reset:
|
|
on_reset()
|
|
|
|
if on_model_change:
|
|
on_model_change("gemini-3.7-flash-auto", target_eff)
|
|
|
|
is_fa = (session.language or "").lower() in ("fa", "farsi", "persian", "🇮🇷 persian / farsi (فارسی)")
|
|
if on_thought:
|
|
if target_eff == "low":
|
|
thought_msg = (
|
|
f"⚡ <b>سادگی تسک تشخیص داده شد:</b> انتقال به <code>Low Effort</code> برای سرعت و صرفهجویی"
|
|
+ (f"\n<i>دلیل: {esc_reason}</i>" if esc_reason else "")
|
|
if is_fa else
|
|
f"⚡ <b>Simple Task Detected:</b> Switching to <code>Low Effort</code> for speed and quota efficiency"
|
|
+ (f"\n<i>Reason: {esc_reason}</i>" if esc_reason else "")
|
|
)
|
|
else:
|
|
thought_msg = (
|
|
f"🧠 <b>تشخیص پیچیدگی تسک:</b> ارتقای خودکار استدلال به <code>{target_eff.upper()} Effort</code>"
|
|
+ (f"\n<i>دلیل: {esc_reason}</i>" if esc_reason else "")
|
|
if is_fa else
|
|
f"🧠 <b>Task Complexity Detected:</b> Auto-escalating reasoning to <code>{target_eff.upper()} Effort</code>"
|
|
+ (f"\n<i>Reason: {esc_reason}</i>" if esc_reason else "")
|
|
)
|
|
on_thought(thought_msg)
|
|
|
|
# Second attempt: Run with the chosen effort without auto evaluation prompt
|
|
result2, _, _ = await cls._execute_single_run(
|
|
session=session,
|
|
prompt=prompt,
|
|
target_model="gemini-3.7-flash",
|
|
target_effort=target_eff,
|
|
is_auto_eval=False,
|
|
on_delta=on_delta,
|
|
on_thought=on_thought,
|
|
on_tool=on_tool,
|
|
)
|
|
if result2:
|
|
result2.executed_model = "gemini-3.7-flash-auto"
|
|
result2.executed_effort = target_eff
|
|
return result2
|
|
|
|
if result:
|
|
result.executed_model = "gemini-3.7-flash-auto"
|
|
result.executed_effort = "medium"
|
|
return result
|
|
|
|
# Standard execution for all other models
|
|
if on_model_change:
|
|
on_model_change(session.model, session.effort)
|
|
|
|
result, _, _ = await cls._execute_single_run(
|
|
session=session,
|
|
prompt=prompt,
|
|
target_model=session.model,
|
|
target_effort=session.effort,
|
|
is_auto_eval=False,
|
|
on_delta=on_delta,
|
|
on_thought=on_thought,
|
|
on_tool=on_tool,
|
|
)
|
|
return result or AgentResult(text="✅ Done", executed_model=session.model, executed_effort=session.effort)
|