سلام من یک چت بات درست کردم در اسپیس هاگینگ فیس ای...
Created on: August 11, 2025
Answered using GPT-5 Thinking by Chat01
Created on: August 11, 2025
Answered using GPT-5 Thinking by Chat01
سلام من یک چت بات درست کردم در اسپیس هاگینگ فیس اینم کد هایش FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7860
CMD ["gunicorn", "--workers", "5", "--worker-class", "gevent", "--bind", "0.0.0.0:7860", "--timeout", "720", "app:app"]
import re
import requests
import json
from flask import Flask, render_template, request, Response
import logging
from filelock import FileLock
from pathlib import Path
import base64
class PersianLogFormatter(logging.Formatter):
LEVEL_MAP = {
logging.DEBUG: "دیباگ",
logging.INFO: "اطلاع",
logging.WARNING: "هشدار",
logging.ERROR: "خطا",
logging.CRITICAL: "بحرانی",
}
def format(self, record):
record.levelname = self.LEVEL_MAP.get(record.levelno, record.levelname)
return super().format(record)
def setup_logging():
log_format = '[%(asctime)s] [%(levelname)s]: %(message)s'
date_format = '%Y-%m-%d %H:%M:%S'
formatter = PersianLogFormatter(log_format, datefmt=date_format)
root_logger = logging.getLogger()
if root_logger.hasHandlers():
root_logger.handlers.clear()
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
root_logger.setLevel(logging.INFO)
setup_logging()
try:
ALL_GEMINI_API_KEYS_STR = os.getenv('ALL_GEMINI_API_KEYS')
if not ALL_GEMINI_API_KEYS_STR:
raise RuntimeError("متغیر ALL_GEMINI_API_KEYS تنظیم نشده است.")
MASTER_API_KEYS = [key.strip() for key in ALL_GEMINI_API_KEYS_STR.split(',') if key.strip()]
if not MASTER_API_KEYS:
raise RuntimeError("هیچ کلید معتبری در ALL_GEMINI_API_KEYS یافت نشد.")
textCOUNTER_FILE_PATH = '/dev/shm/gunicorn_key_counter.txt' lock_path = COUNTER_FILE_PATH + ".lock" lock = FileLock(lock_path) with lock: if not os.path.exists(COUNTER_FILE_PATH): logging.info(f"✅ اولین کارگر شروع به کار کرد. با موفقیت {len(MASTER_API_KEYS)} کلید Gemini بارگذاری شد.") with open(COUNTER_FILE_PATH, 'w') as f: f.write('0') logging.info("شمارنده چرخش کلیدها مقداردهی اولیه شد.") CACHE_DIR = Path('/dev/shm/file_cache') CACHE_DIR.mkdir(parents=True, exist_ok=True) logging.info(f"پوشه کش فایلها در مسیر '{CACHE_DIR}' آماده استفاده است.") META_DIR = Path('/dev/shm/chat_meta') META_DIR.mkdir(parents=True, exist_ok=True) logging.info(f"پوشه متادیتای چتها در مسیر '{META_DIR}' آماده استفاده است.")
except Exception as e:
logging.critical(f"خطای بحرانی در هنگام بارگذاری کلیدهای API یا تنظیم کش/متا: {e}")
raise
app = Flask(name)
GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models'
BASE_RULES_FA = """
دستورالعملهای اجباری برای دستیار هوش مصنوعی آلفا:
هویت: تو «چتبات آلفا» هستی که توسط هوش مصنوعی آلفا توسعه داده شدهای. وظیفهات کمک به کاربر در همهی زمینههاست.
پرهیز از امتناع بیجا: به جای امتناع، راهحل جایگزین/ایمن بده.
تحلیل جامع چندرسانهای:
هر نوع فایل (تصویر، ویدیو، صدا، PDF، متن، کُد و...) را تحلیل و محتوا را شرح بده.
پاسخ را با تشخیص نوع فایل شروع کن (مثلاً: «این یک فایل صوتی است که...»).
اگر کاربر فقط فایل فرستاد و صراحتاً ساخت/ویرایش تصویر نخواست، فقط تحلیل کن (ابزار تصویر را فراخوانی نکن).
تصویر (ابزارها الزامی در ساخت/ویرایش):
اولویت ابزارها: handle_specific_edit > change_image_aspect_ratio > regenerate_with_enhancement > generate_image > perform_internet_search
خروجی ابزار باید functionCall واقعی باشد؛ شبهکد مثل print(handle_specific_edit(...)) یا متنهایی مانند functionCall: ... ممنوع.
ویرایشهای پیاپی: اگر دستور تکمیلی آمد، با دستور قبلی ادغام کن و بعد ویرایش را انجام بده.
گفتگو: اگر پیام محاورهای/تشکر بود، ابزار تصویر را نخوان؛ پاسخ کوتاه و دوستانه فارسی بده.
درخواستهای غیرتصویری (کدنویسی، نگارش، آموزش و...): پاسخ مفید بده؛ برای کد، بلاک Markdown (```lang) بده.
"""
def get_and_increment_key_index():
lock_path = COUNTER_FILE_PATH + ".lock"
lock = FileLock(lock_path)
with lock:
try:
with open(COUNTER_FILE_PATH, 'r') as f:
current_index = int(f.read().strip())
except (FileNotFoundError, ValueError):
current_index = 0
index_to_use = current_index % len(MASTER_API_KEYS)
next_index = (current_index + 1)
with open(COUNTER_FILE_PATH, 'w') as f:
f.write(str(next_index))
return index_to_use
def get_keys_for_request():
start_index = get_and_increment_key_index()
return MASTER_API_KEYS[start_index:] + MASTER_API_KEYS[:start_index]
def meta_path(chat_id: str) -> Path:
safe_id = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', ''))
return META_DIR / f"{safe_id}.json"
def load_chat_meta(chat_id: str) -> dict:
path = _meta_path(chat_id)
lock = FileLock(str(path) + ".lock")
with lock:
if path.exists():
try:
return json.load(open(path, 'r', encoding='utf-8'))
except Exception as e:
logging.warning(f"خواندن متای چت {chat_id} ناموفق بود: {e}")
return {}
return {}
def save_chat_meta(chat_id: str, meta: dict):
path = _meta_path(chat_id)
lock = FileLock(str(path) + ".lock")
with lock:
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(meta or {}, f, ensure_ascii=False)
except Exception as e:
logging.warning(f"ذخیره متای چت {chat_id} ناموفق بود: {e}")
def update_chat_meta(chat_id: str, **kwargs):
meta = load_chat_meta(chat_id)
meta.update({k: v for k, v in kwargs.items() if v is not None})
save_chat_meta(chat_id, meta)
def get_all_tools():
search_tool = {
"name": "perform_internet_search",
"description": "فقط برای جستجوی اطلاعات بهروز، وقایع جاری، قیمتها و... استفاده شود.",
"parameters": {"type": "OBJECT","properties": {"query": {"type": "STRING"}},"required": ["query"]}
}
image_tools = [
{
"name": "generate_image",
"description": "ساخت تصویر جدید (نه توصیف متنی).",
"parameters": {"type": "OBJECT","properties": {
"english_prompt": {"type": "STRING"},
"aspect_ratio": {"type": "STRING"},
"initial_response_text": {"type": "STRING"},
"follow_up_text": {"type": "STRING"}
},"required": ["english_prompt", "initial_response_text", "follow_up_text"]}
},
{
"name": "handle_specific_edit",
"description": "تغییر مشخص روی تصویر قبلی؛ دستور جدید را در صورت لزوم با قبلی ادغام کن.",
"parameters": {"type": "OBJECT","properties": {"edit_request": {"type": "STRING"}},"required": ["edit_request"]}
},
{
"name": "regenerate_with_enhancement",
"description": "برای درخواستهای کلی/مبهم؛ نیاز به رفع ابهام.",
"parameters": {"type": "OBJECT","properties": {
"enhancement_request": {"type": "STRING"},
"previous_english_prompt": {"type": "STRING"},
"previous_aspect_ratio": {"type": "STRING"}
},"required": ["enhancement_request", "previous_english_prompt", "previous_aspect_ratio"]}
},
{
"name": "change_image_aspect_ratio",
"description": "تغییر نسبت/اندازه تصویر قبلی.",
"parameters": {"type": "OBJECT","properties": {
"new_aspect_ratio": {"type": "STRING"},
"previous_english_prompt": {"type": "STRING"}
},"required": ["new_aspect_ratio", "previous_english_prompt"]}
}
]
return [{"function_declarations": image_tools + [search_tool]}]
def enhance_prompt(base_prompt, enhancement_request, model):
api_key = get_keys_for_request()[0]
url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent?key={api_key}"
system_prompt = f"""You are an expert prompt engineer. Merge the base English image prompt with the user's modification (Persian or English). Return only the final, ready-to-use English prompt.
Base Prompt: "{base_prompt}"
User's Request: "{enhancement_request}"
"""
payload = {"contents": [{"role": "user", "parts": [{"text": system_prompt}]}],"generationConfig": { "temperature": 0.7 }}
try:
response = requests.post(url, json=payload, timeout=45)
response.raise_for_status()
enhanced_prompt = response.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
logging.info(f"✅ پرامپت بهبود یافت: {enhanced_prompt}")
return enhanced_prompt
except Exception as e:
logging.error(f"❌ خطا در enhance_prompt: {e}")
return f"{base_prompt}, {enhancement_request}"
def create_artistic_prompt(user_prompt, model):
api_key = get_keys_for_request()[0]
url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent?key={api_key}"
system_instruction = "Convert user's (possibly Persian) idea to a highly-detailed English prompt. Output ONLY the final English prompt."
payload = {
"contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
"systemInstruction": {"parts": [{"text": system_instruction}]},
"generationConfig": { "temperature": 0.7 }
}
try:
r = requests.post(url, json=payload, timeout=45)
r.raise_for_status()
return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
except Exception as e:
logging.error(f"❌ خطا در create_artistic_prompt: {e}")
return user_prompt
def merge_edit_prompt(chat_id: str, user_edit_request: str, model: str) -> str:
meta = load_chat_meta(chat_id)
base_prompt = (meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "").strip()
if base_prompt:
try:
return enhance_prompt(base_prompt, user_edit_request, model) or user_edit_request
except Exception as e:
logging.warning(f"ادغام پرامپت ویرایش ناموفق: {e}")
return user_edit_request
return user_edit_request
def stream_search_results(query):
logging.info(f"🚀 جستجو برای: '{query}'")
keys_to_try = get_keys_for_request()
search_model = 'gemini-2.5-flash'
url = f"{GOOGLE_API_BASE_URL}/{search_model}:streamGenerateContent?alt=sse"
payload = {"contents": [{"role": "user", "parts": [{"text": query}]}],"tools": [{"google_search": {}}],"systemInstruction": {"parts": [{"text": "Answer in Persian."}]}}
for api_key in keys_to_try:
try:
with requests.post(url, params={'key': api_key}, json=payload, stream=True, timeout=120) as response:
if response.status_code == 429:
logging.warning("کلید جستجو مسدود. کلید بعدی...")
continue
response.raise_for_status()
for line in response.iter_lines():
if line:
yield f"{line.decode('utf-8')}\n\n"
return
except requests.exceptions.RequestException as e:
logging.warning(f"خطای جستجو: {e}. کلید بعدی...")
continue
yield f"data: {json.dumps({'error': {'code': 'SEARCH_FAILED','message': 'سرویس جستجو موقتاً در دسترس نیست.'}})}\n\n"
def is_tool_like_text(txt: str) -> bool:
if not txt: return False
patterns = [
r'\bfunctionCall\s*:', r'\bhandle_specific_edit\s*(', r'\bchange_image_aspect_ratio\s*(',
r'\bregenerate_with_enhancement\s*(', r'\bgenerate_image\s*(', r'print\s*(\s*handle_specific_edit',
]
return any(re.search(p, txt, flags=re.I) for p in patterns)
def sse_text_event(text: str) -> str:
return f"data: {json.dumps({'candidates':[{'content':{'parts':[{'text': text}]}}]})}\n\n"
def classify_user_intent(user_text: str) -> dict:
if not user_text or not user_text.strip():
return {"intent":"NONE"}
keys_to_try = get_keys_for_request()
classify_model = 'gemini-1.5-flash'
url = f"{GOOGLE_API_BASE_URL}/{classify_model}:generateContent"
system = (
"You classify ONLY the latest message.\n"
"Return strict JSON: {"intent":"...", "normalized_edit":"...", "new_aspect_ratio":"...", "code_language":"..."}\n"
"intents=[NONE,SPECIFIC_EDIT,ASPECT_RATIO_CHANGE,QUALITY_ENHANCEMENT,NEW_IMAGE,CODE_TASK]. "
"Thanks/ack/greetings => NONE. Code/markup requests => CODE_TASK."
)
payload = {
"contents": [{"role":"user","parts":[{"text": user_text}]}],
"systemInstruction": {"parts":[{"text": system}]},
"generationConfig": {"temperature": 0.0, "maxOutputTokens": 128}
}
for api_key in keys_to_try:
try:
r = requests.post(f"{url}?key={api_key}", json=payload, timeout=20)
if r.status_code == 429: continue
r.raise_for_status()
txt = r.json()["candidates"][0]["content"]["parts"][0]["text"].strip().strip('`').strip()
s, e = txt.find('{'), txt.rfind('}')
if s != -1 and e != -1: txt = txt[s:e+1]
data = json.loads(txt)
if "intent" not in data: return {"intent":"NONE"}
return data
except Exception as e:
logging.warning(f"Intent classify failed: {e}")
continue
return {"intent":"NONE"}
def stream_text_only(user_text: str, model_name: str):
keys_to_try = get_keys_for_request()
system_text = BASE_RULES_FA + """
[TURN MODE: TEXT ONLY]
ابزارها را فراخوانی نکن. یک پاسخ کوتاه، دوستانه و فارسی بده.
هرگز نام ابزار یا واژههایی مانند functionCall را در خروجی ننویس.
از عذرخواهی/محدودیتگویی اجتناب کن.
"""
contents = [{"role":"user","parts":[{"text": user_text }]}]
for api_key in keys_to_try:
try:
url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}"
payload = { "contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 128} }
with requests.post(url, json=payload, stream=True, timeout=60) as resp:
if resp.status_code == 429: continue
resp.raise_for_status()
for line in resp.iter_lines():
if line and line.startswith(b"data: "):
yield f"{line.decode('utf-8')}\n\n"
return
except requests.exceptions.RequestException as e:
logging.warning(f"Text-only error: {e}")
continue
yield f"data: {json.dumps({'error': {'code':'TEXT_STREAM_FAILED','message':'پاسخ متنی موقتاً در دسترس نیست.'}})}\n\n"
def stream_code_reply(user_text: str, model_name: str, code_language: str = None):
keys_to_try = get_keys_for_request()
lang = (code_language or "").lower()
if lang not in {"html","css","javascript","python","sql","bash","json","yaml","xml","markdown","typescript","csharp","java","c","cpp","php","go","rust","kotlin","swift"}:
lang = "html"
system_text = BASE_RULES_FA + f"""
[TURN MODE: CODE]
هیچ ابزاری را فراخوانی نکن. کد کامل و قابل اجرا تولید کن داخل ```{lang}. توضیح کوتاه فارسی مجاز، تمرکز روی کد.
"""
contents = [{"role":"user","parts":[{"text": user_text }]}]
for api_key in keys_to_try:
try:
url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}"
payload = { "contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.2, "maxOutputTokens": 2048} }
with requests.post(url, json=payload, stream=True, timeout=120) as response:
if response.status_code == 429: continue
response.raise_for_status()
for line in response.iter_lines():
if line: yield line.decode('utf-8') + "\n\n"
return
except requests.exceptions.RequestException as e:
logging.warning(f"Code stream error: {e}")
continue
yield f"data: {json.dumps({'error': {'code':'CODE_STREAM_FAILED','message':'تولید کد موقتاً در دسترس نیست.'}})}\n\n"
def stream_file_analysis(history_for_gemini, model_name: str):
keys_to_try = get_keys_for_request()
system_text = BASE_RULES_FA + """
[TURN MODE: FILE ANALYSIS]
ابزارها را فراخوانی نکن. فایل ضمیمهشده در آخرین پیام کاربر را تحلیل کن: نوع فایل را دقیق تشخیص بده و محتوای آن را خلاصه و منظم شرح بده.
اگر تصویر است و کاربر صرفاً تحلیل خواسته، فقط محتوای تصویر را توضیح بده (نه ساخت/ویرایش).
"""
for api_key in keys_to_try:
try:
url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}"
payload = { "contents": history_for_gemini, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048} }
with requests.post(url, json=payload, stream=True, timeout=180) as response:
if response.status_code == 429: continue
response.raise_for_status()
for line in response.iter_lines():
if line: yield line.decode('utf-8') + "\n\n"
return
except requests.exceptions.RequestException as e:
logging.warning(f"File analysis error: {e}")
continue
yield f"data: {json.dumps({'error': {'code':'FILE_ANALYSIS_FAILED','message':'تحلیل فایل موقتاً در دسترس نیست.'}})}\n\n"
@app.route('/')
def index():
return render_template('index.html')
@app.route('/chat', methods=['POST'])
def chat():
data = request.json
if not data:
return Response(json.dumps({"error": "Invalid request"}), status=400, mimetype='application/json')
textmodel = data.get('model', 'gemini-2.5-flash') history = data.get('history', []) action_payload = data.get('action') chat_id = data.get('chatId') if not chat_id: return Response(json.dumps({"error": "chatId is required"}), status=400, mimetype='application/json') # #################################################################### # START: منطق جدید و اصلاح شده برای مدیریت فایل # این بلوک کد، مشکل اصلی شما را حل میکند # #################################################################### last_user_message = None # آخرین پیام کاربر را پیدا کن for i in range(len(history) - 1, -1, -1): if history[i].get('role') == 'user': last_user_message = history[i] break if last_user_message: cache_file_path = CACHE_DIR / f"{chat_id}.json" lock_path = str(cache_file_path) + ".lock" cache_lock = FileLock(lock_path) parts = last_user_message.get('parts', []) # چک کن آیا کلاینت در این پیام یک فایل جدید فرستاده یا نه inline_data_part = next((p for p in parts if 'inlineData' in p), None) with cache_lock: if inline_data_part: # یک فایل جدید آپلود شده است. آن را در کش ذخیره میکنیم. try: with open(cache_file_path, 'w') as f: json.dump(inline_data_part['inlineData'], f) logging.info(f"✅ فایل جدید برای چت {chat_id} دریافت و در کش ذخیره شد.") except Exception as e: logging.error(f"❌ خطا در ذخیره فایل در کش برای چت {chat_id}: {e}") else: # فایل جدیدی ارسال نشده. تلاش میکنیم فایل را از کش بازیابی کنیم (برای سوالات بعدی) if cache_file_path.exists(): try: with open(cache_file_path, 'r') as f: cached_data = json.load(f) # فایل کش شده را به پیام فعلی کاربر اضافه میکنیم تا مدل زمینه را داشته باشد parts.insert(0, {'inlineData': cached_data}) last_user_message['parts'] = parts logging.info(f"✅ فایل از کش برای چت {chat_id} بازیابی و به تاریخچه اضافه شد.") except Exception as e: logging.warning(f"⚠️ خواندن فایل از کش برای چت {chat_id} ناموفق بود: {e}") # #################################################################### # END: منطق جدید و اصلاح شده برای مدیریت فایل # #################################################################### # -------- اکشن مستقیم clarify -------- if action_payload and action_payload.get('intent') == 'regenerate_with_enhancement': def stream_action_result(): try: base_prompt = action_payload.get("base_english_prompt") enhancement_request = action_payload.get("enhancement_request") aspect_ratio = action_payload.get("aspect_ratio", "9:16") if not base_prompt or not enhancement_request: yield f"data: {json.dumps({'error': {'code':'MISSING_ARGS','message':'پارامترهای لازم موجود نیست.'}})}\n\n" return logging.info(f"🚀 بهبود پرامپت برای بازسازی: '{enhancement_request}'") new_prompt = enhance_prompt(base_prompt, enhancement_request, model) yield f"data: {json.dumps({'intent':'generate_image','english_prompt': new_prompt,'aspect_ratio': aspect_ratio})}\n\n" except Exception as e: logging.error(f"❌ خطای اجرای اکشن: {e}") yield f"data: {json.dumps({'error': {'code':'ACTION_EXECUTION_FAILED','message': str(e)}})}\n\n" return Response(stream_action_result(), mimetype='text/event-stream') # ------------------------ استریم اصلی ------------------------ def stream_events(): def send_event(event_data): return f"data: {json.dumps(event_data)}\n\n" history_for_gemini = [] for msg in history: if 'role' not in msg: continue new_msg = {'role': msg['role'], 'parts': []} for part in (msg.get('parts') or []): if isinstance(part, dict) and any(k in part for k in ['text','inlineData','functionCall','functionResponse']): new_msg['parts'].append(part) if new_msg['parts']: history_for_gemini.append(new_msg) last_user_idx_local = -1 for i in range(len(history_for_gemini) - 1, -1, -1): if history_for_gemini[i].get('role') == 'user': last_user_idx_local = i break last_user_text = "" has_inline_file = False if last_user_idx_local != -1: lu = history_for_gemini[last_user_idx_local] last_user_text = ''.join(p.get('text','') for p in (lu.get('parts') or []) if p.get('text')).strip() has_inline_file = any('inlineData' in p for p in (lu.get('parts') or [])) image_in_recent_history = any( m.get('role') == 'model' and any('image_url' in p or 'edited_images' in p for p in (m.get('parts') or [])) for m in history[-4:] ) intent_info = classify_user_intent(last_user_text) if last_user_text else {"intent":"NONE"} intent = (intent_info.get("intent") or "NONE").upper() logging.info(f"Intent: {intent} | has_file={has_inline_file} | image_recent={image_in_recent_history} | text='{last_user_text[:50]}'") # 1) کدنویسی if intent == "CODE_TASK": yield from stream_code_reply(last_user_text, model, intent_info.get("code_language")) return # 2) بعد از تصویر: تشکر/گفتوگو => متن کوتاه if image_in_recent_history and last_user_text and intent == "NONE": yield from stream_text_only(last_user_text, model) return # 3) اگر فایل هست و کاربر درخواست خاصی (مثل کد) ندارد => تحلیل فایل if has_inline_file and intent in {"NONE"}: yield from stream_file_analysis(history_for_gemini, model) return # 4) مسیرهای تصویر بر اساس intent if image_in_recent_history and last_user_text: if intent == "SPECIFIC_EDIT": merged = merge_edit_prompt(chat_id, intent_info.get("normalized_edit") or last_user_text, model) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent": "edit_image", "prompt": merged}) return elif intent == "ASPECT_RATIO_CHANGE": meta = load_chat_meta(chat_id) ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = intent_info.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent": "generate_image", "english_prompt": ep, "aspect_ratio": new_ar}) return elif intent == "QUALITY_ENHANCEMENT": meta = load_chat_meta(chat_id) prev_ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = meta.get("last_aspect_ratio") or "9:16" enh = intent_info.get("normalized_edit") or last_user_text yield send_event({ "intent": "clarify_action", "question": "بسیار خب! تصویر فعلی را ویرایش کنم یا یک تصویر جدید بسازم؟", "options": { "edit": {"label": "ویرایش همین تصویر", "intent": "edit_image", "prompt": enh}, "regenerate": {"label": "ساخت تصویر جدید", "intent": "regenerate_with_enhancement", "base_english_prompt": prev_ep, "enhancement_request": enh, "aspect_ratio": prev_ar} } }) return elif intent == "NEW_IMAGE": ep = create_artistic_prompt(last_user_text, model) update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio="9:16", last_edit_prompt=None) yield send_event({ "intent": "generate_image_with_text", "text": "در حال ساخت تصویر جدید برای شما...", "image_generation_payload": {"english_prompt": ep, "aspect_ratio": "9:16"}, "follow_up_text": "تصویر شما آماده شد!" }) return # 5) مسیر جنرال با ابزارهای تصویر tools_for_request = get_all_tools() final_system_prompt = BASE_RULES_FA + """\n[TURN MODE: GENERAL]\nبرای درخواستهای تصویر از ابزارها استفاده کن؛ در غیر این صورت پاسخ متنی/کُد بده.\nاز شبهکد برای ابزارها خودداری کن (فقط functionCall واقعی). هرگز متنهایی مانند 'functionCall: ...' در خروجی ننویس.""" keys_to_try = get_keys_for_request() accumulated_function_call_args = None function_call_name = None for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse&key={api_key}" payload = {"contents": history_for_gemini, "tools": tools_for_request, "systemInstruction": {"parts": [{"text": final_system_prompt}]}} with requests.post(url, json=payload, stream=True, timeout=720) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line and line.startswith(b'data: '): chunk_str = line.decode('utf-8')[6:] try: data_chunk = json.loads(chunk_str) part = data_chunk.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0] if "functionCall" in part: if not accumulated_function_call_args: accumulated_function_call_args = {} function_call_name = part["functionCall"].get("name") args_chunk = part["functionCall"].get("args", {}) for k, v in (args_chunk or {}).items(): if k not in accumulated_function_call_args: accumulated_function_call_args[k] = v elif isinstance(accumulated_function_call_args.get(k), str): accumulated_function_call_args[k] += str(v) elif "text" in part: if not is_tool_like_text(part["text"]): yield sse_text_event(part["text"]) except (json.JSONDecodeError, IndexError): continue # Loop finished for this key. Now process any complete function call. if accumulated_function_call_args: args = accumulated_function_call_args logging.info(f"✅ functionCall: {function_call_name} args={args}") if function_call_name == "handle_specific_edit": raw_req = (args.get("edit_request") or "").strip() if not raw_req: yield send_event({"error": {"code":"EMPTY_EDIT_REQUEST","message":"دستور ویرایش دریافت نشد."}}) else: merged = merge_edit_prompt(chat_id, raw_req, model) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent":"edit_image","prompt": merged}) elif function_call_name == "regenerate_with_enhancement": meta = load_chat_meta(chat_id) prev_ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = args.get("previous_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" enhancement_request = args.get("enhancement_request", "") yield send_event({"intent":"clarify_action", "question":"بسیار خب! با این تغییرات چه کنم؟", "options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enhancement_request}, "regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enhancement_request,"aspect_ratio": prev_ar}}}) elif function_call_name == "change_image_aspect_ratio": meta = load_chat_meta(chat_id) ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = args.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent":"generate_image","english_prompt": ep,"aspect_ratio": new_ar}) elif function_call_name == "generate_image": ep = args.get("english_prompt") ar = args.get("aspect_ratio", "9:16") update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio=ar, last_edit_prompt=None) yield send_event({"intent":"generate_image_with_text", "text": args.get("initial_response_text"), "image_generation_payload": {"english_prompt": ep,"aspect_ratio": ar}, "follow_up_text": args.get("follow_up_text")}) elif function_call_name == "perform_internet_search": yield from stream_search_results(args.get('query')) return # Successfully processed with this key. except requests.exceptions.RequestException as e: logging.warning(f"خطا در ارتباط با Gemini: {e}. کلید بعدی...") continue # Try next key yield send_event({"error": {"code": "ALL_KEYS_FAILED", "message": "تمام کلیدهای API خطا دادند یا درخواست نامعتبر بود."}}) return Response(stream_events(), mimetype='text/event-stream')
if name == 'main':
app.run(debug=True, host='0.0.0.0', port=os.environ.get("PORT", 7860)) این یکی Flask==2.2.2
Werkzeug==2.2.2
gunicorn==20.1.0
requests==2.28.1
gevent==22.10.2
sseclient-py==1.8.0
filelock==3.13.1 این یکی <!DOCTYPE html>
</head> <body class="bg-slate-100 dark:bg-slate-900"> <svg width="0" height="0" style="position: absolute; overflow: hidden;"> <defs> <filter id="shadowAndHighlight"> <feDropShadow dx="-0.5" dy="-0.5" stdDeviation="0" flood-color="#FFFFFF" flood-opacity="0.8"/> <feDropShadow dx="1" dy="1" stdDeviation="0" flood-color="#888888" flood-opacity="0.8"/> </filter> </defs> </svg>text<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css"> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</body> </html> این یکی html, body { height: 100%; overflow: hidden; } body { font-family: 'Vazirmatn', 'Inter', sans-serif; -webkit-tap-highlight-color: transparent; } .custom-scrollbar::-webkit-scrollbar { width: 8px; height: 8px; } .custom-scrollbar::-webkit-scrollbar-track { background: transparent; } .custom-scrollbar::-webkit-scrollbar-thumb { background-color: rgba(209, 213, 219, 0.7); border-radius: 10px; } .dark .custom-scrollbar::-webkit-scrollbar-thumb { background-color: rgba(107, 114, 128, 0.5); } .hidden { display: none !important; } #history-sidebar, #confirm-modal-content, #rename-modal-content, #edit-modal-content, #html-preview-content, #settings-modal-content { transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); } #sidebar-overlay, #confirm-modal-overlay, #rename-modal-overlay, #edit-modal-overlay, #html-preview-overlay, #settings-modal #settings-modal-overlay { transition: opacity 300ms ease-in-out; } #modal-content { transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); }text<div id="sidebar-overlay" class="fixed inset-0 bg-black bg-opacity-40 dark:bg-opacity-60 z-40 hidden opacity-0"></div> <aside id="history-sidebar" class="fixed top-0 right-0 h-full bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg border-l border-slate-200/50 dark:border-slate-700/50 w-72 p-4 flex flex-col z-50 transform translate-x-full"> <div class="flex items-center justify-between mb-4 px-2"> <h2 class="text-lg font-bold text-slate-800 dark:text-slate-200">تاریخچه چتها</h2> <button id="delete-all-chats" title="حذف تمام چتها" class="p-2 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-full transition-colors"> <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg> </button> </div> <nav id="history-list" class="flex-grow overflow-y-auto custom-scrollbar -mr-2 pr-2"></nav> <div id="sidebar-footer" class="flex-shrink-0 pt-4 border-t border-slate-200/60 dark:border-slate-700/60"> <button id="settings-button" class="sidebar-button"> <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.438.995s.145.755.438.995l1.003.827c.48.398.668 1.03.26 1.431l-1.296 2.247a1.125 1.125 0 01-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 01-.22-.127c-.324-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 01-1.37-.49l-1.296-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.437-.995s-.145-.755-.437-.995l-1.004-.827a1.125 1.125 0 01-.26-1.431l1.296-2.247a1.125 1.125 0 011.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.645-.869l.213-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <span>تنظیمات</span> </button> </div> </aside> <div id="app-container" class="h-full w-full max-w-3xl mx-auto shadow-2xl bg-slate-100 dark:bg-slate-900"> <header id="main-header" class="flex items-center justify-between p-4 bg-slate-100/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/80 z-20"> <button id="menu-button" class="p-2 text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-4.125 6h-8.25" /></svg> </button> <button id="model-select-button" class="px-4 py-2 text-sm font-semibold text-white bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg shadow-md hover:shadow-lg transition-all duration-300 flex items-center gap-2"> <span id="current-model-name"></span> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /></svg> </button> <button id="new-chat-button" class="p-2 text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /></svg> </button> </header> <main id="chat-window" class="flex-grow w-full p-4 sm:p-6 overflow-y-auto custom-scrollbar"></main> <footer id="main-footer" class="flex-shrink-0 px-4 sm:px-6 pb-4 sm:pb-6 z-10 pt-4 bg-gradient-to-t from-slate-100 via-slate-100 to-transparent dark:from-slate-900 dark:via-slate-900"> <div id="image-preview-container" class="mb-3 relative p-1.5 bg-white/50 dark:bg-slate-700/50 backdrop-blur-md rounded-lg border border-slate-200 dark:border-slate-600 shadow-sm hidden"> <img id="image-preview" src="" alt="Preview" class="h-20 w-20 object-cover rounded-md flex-shrink-0" /> <span id="file-info-text" class="mr-2 text-slate-800 dark:text-slate-200"></span> <button id="remove-image-button" class="absolute -top-2 -left-2 bg-slate-700 text-white rounded-full p-0.5 leading-none shadow-md hover:bg-red-600 transition-colors"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg> </button> </div> <form id="message-form" class="flex items-center bg-white/60 dark:bg-slate-800/60 backdrop-blur-lg p-2 border border-slate-200/80 dark:border-slate-700/80 shadow-md focus-within:ring-2 focus-within:ring-blue-500 rounded-full"> <textarea id="message-input" rows="1" placeholder="از من هرچه میخواهی بپرس..." class="flex-grow bg-transparent text-slate-800 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none px-4 text-right" autocomplete="off"></textarea> <input type="file" id="image-file-input" class="hidden" accept="image/*"> <!-- ##################################################################### --> <!-- START: کد اصلاح شده --> <!-- ویژگی accept حذف شد تا همه نوع فایلی قابل انتخاب باشد --> <input type="file" id="general-file-input" class="hidden"> <!-- ##################################################################### --> <!-- END: کد اصلاح شده --> <!-- ##################################################################### --> <button type="button" id="attach-file-button" class="p-2 text-slate-500 hover:text-blue-600 dark:text-slate-400 dark:hover:text-blue-500 transition-colors"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.122 2.122l7.81-7.81" /></svg> </button> <button id="submit-button" type="submit" class="p-2 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-white disabled:from-slate-300 disabled:to-slate-400 dark:disabled:from-slate-500 dark:disabled:to-slate-600 transition-all flex items-center justify-center w-10 h-10 flex-shrink-0"> <svg id="send-icon" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg> <svg id="stop-icon" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5 5a1 1 0 00-1 1v8a1 1 0 001 1h8a1 1 0 001-1V6a1 1 0 00-1-1H5z" clip-rule="evenodd"></path></svg> </button> </form> </footer> </div> <div id="model-select-modal" class="fixed inset-0 bg-black bg-opacity-40 dark:bg-opacity-60 backdrop-blur-sm flex items-center justify-center p-4 z-50 hidden"> <div id="modal-content" class="bg-white dark:bg-slate-800 rounded-2xl shadow-xl w-full max-w_sm p-6 transform"> <h2 class="text-xl font-bold text-center mb-6 text-slate-800 dark:text-white">یک مدل را انتخاب کنید</h2> <div class="space-y-4"> <button data-model="gemini-2.5-flash" data-name="چت بات سریع آلفا" class="model-option-card w-full text-right p-4 border dark:border-slate-700 rounded-xl hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-slate-700/50 transition-all flex items-center gap-4"> <div class="w-5 h-5 border-2 border-slate-400 dark:border-slate-500 rounded-full flex items-center justify-center flex-shrink-0 radio-icon"><div class="w-2.5 h-2.5 bg-blue-500 rounded-full inner-dot hidden"></div></div> <div> <p class="font-semibold text-slate-800 dark:text-slate-200">چت بات سریع آلفا</p><p class="text-sm text-slate-500 dark:text-slate-400">مناسب برای سرعت و کارهای روزمره</p> </div> </button> <button data-model="gemini-2.5-pro" data-name="چت بات پیشرفته آلفا" class="model-option-card w-full text-right p-4 border dark:border-slate-700 rounded-xl hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-slate-700/50 transition-all flex items-center gap-4"> <div class="w-5 h-5 border-2 border-slate-400 dark:border-slate-500 rounded-full flex items-center justify-center flex-shrink-0 radio-icon"><div class="w-2.5 h-2.5 bg-blue-500 rounded-full inner-dot hidden"></div></div> <div> <p class="font-semibold text-slate-800 dark:text-slate-200">چت بات پیشرفته آلفا</p><p class="text-sm text-slate-500 dark:text-slate-400">برای پاسخهای دقیقتر و تحلیل فایلها</p> </div> </button> </div> </div> </div> <div id="settings-modal" class="fixed inset-0 bg-black bg-opacity-40 dark:bg-opacity-60 backdrop-blur-sm flex items-center justify-center p-4 z-50 hidden"> <div id="settings-modal-content" class="bg-white dark:bg-slate-800 rounded-2xl shadow-xl w-full max-w_sm p-6 transform"> <h2 class="text-xl font-bold text-center mb-6 text-slate-800 dark:text-white">تنظیمات</h2> <div class="flex items-center justify-between p-4 rounded-lg bg-slate-100 dark:bg-slate-700/50"> <label for="theme-toggle" class="text-slate-700 dark:text-slate-300 font-medium">حالت تیره</label> <label class="theme-switch"> <input type="checkbox" id="theme-toggle"> <span class="slider round"></span> </label> </div> </div> </div> <div id="history-item-menu" class="fixed bg-white dark:bg-slate-800 rounded-xl shadow-lg p-2 z-50 w-56 border border-slate-200 dark:border-slate-700"> </div> <div id="message-item-menu" class="fixed inset-0 z-50 hidden items-center justify-center p-4"> <div id="message-item-menu-overlay" class="absolute inset-0 bg-black/40 backdrop-blur-sm"></div> <div id="message-item-menu-content" class="relative bg-white dark:bg-slate-800 rounded-2xl shadow-xl w-full max-w-sm p-2 border border-slate-200 dark:border-slate-700"> </div> </div> <div id="confirm-modal" class="fixed inset-0 z-50 hidden"> <div id="confirm-modal-overlay" class="absolute inset-0 bg-black/40"></div> <div class="relative w-full h-full flex items-center justify-center p-4"> <div id="confirm-modal-content" class="bg-white dark:bg-slate-800 rounded-2xl shadow-xl w-full max-w_sm p-6 text-center"> <h3 class="text-lg font-bold text-slate-800 dark:text-white mb-2">تایید عملیات</h3> <p id="confirm-modal-message" class="text-slate-600 dark:text-slate-300 mb-6"></p> <div class="flex justify-center gap-4"> <button id="confirm-modal-cancel-btn" class="px-6 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">انصراف</button> <button id="confirm-modal-confirm-btn" class="px-6 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors">تایید</button> </div> </div> </div> </div> <div id="rename-modal" class="fixed inset-0 z-50 hidden"> <div id="rename-modal-overlay" class="absolute inset-0 bg-black/40"></div> <div class="relative w-full h-full flex items-center justify-center p-4"> <form id="rename-modal-content" class="bg-white dark:bg-slate-800 rounded-2xl shadow-xl w-full max-w_sm p-6"> <h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 text-center">تغییر نام گفتگو</h3> <input id="rename-input" type="text" class="w-full p-2 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none mb-6"> <div class="flex justify-center gap-4"> <button id="rename-modal-cancel-btn" type="button" class="px-6 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">انصراف</button> <button id="rename-modal-confirm-btn" type="submit" class="px-6 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors">تایید</button> </div> </form> </div> </div> <div id="edit-modal" class="fixed inset-0 z-50 hidden"> <div id="edit-modal-overlay" class="absolute inset-0 bg-black/40"></div> <div class="relative w-full h-full flex items-center justify-center p-4"> <form id="edit-modal-content" class="bg-white dark:bg-slate-800 rounded-2xl shadow-xl w-full max-w_lg p-6"> <h3 class="text-lg font-bold text-slate-800 dark:text-white mb-4 text-center">ویرایش پیام</h3> <textarea id="edit-input" rows="5" class="w-full p-2 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none mb-6"></textarea> <div class="flex justify-center gap-4"> <button id="edit-modal-cancel-btn" type="button" class="px-6 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">انصراف</button> <button id="edit-modal-confirm-btn" type="submit" class="px-6 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors">تایید و ارسال مجدد</button> </div> </form> </div> </div> <div id="html-preview-modal" class="hidden"> <div id="html-preview-overlay" class="absolute inset-0 bg-black/40"></div> <div id="html-preview-content" class="bg-white dark:bg-slate-800 rounded-2xl shadow-xl transform"> <div id="html-preview-header" class="bg-slate-50 dark:bg-slate-700"> <h3 class="text-lg font-bold text-slate-800 dark:text-white">پیشنمایش HTML</h3> <button id="html-preview-close-btn" class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg> </button> </div> <div id="html-preview-iframe-container"> <iframe id="html-preview-iframe" srcdoc="" allowfullscreen></iframe> </div> </div> </div> <div id="file-popup-menu" class="hidden bg-white border border-slate-200 dark:border-slate-700"> <button id="select-image-option" class="popup-option"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="svg-3d-effect" style="margin-left: 15px;"> <rect x="3" y="5" width="18" height="14" rx="2" ry="2" fill="#E0E0E0" stroke="#BBB" stroke-width="1"/> <path d="M3 15 L8 10 L13 15 L18 10 L21 15 V19 H3 Z" fill="#999999" stroke="#777" stroke-width="0.5"/> <circle cx="7" cy="8" r="2" fill="#FFD700" stroke="#DAA520" stroke-width="0.5"/> <path class="upload-arrow" d="M12 4 L12 11 M9 8 L12 11 L15 8" stroke="#4CAF50" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> </svg> <span class="text-slate-800">آپلود تصویر</span> </button> <button id="select-file-option" class="popup-option"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="svg-3d-effect" style="margin-left: 15px;"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="#E0E0E0" stroke="#BBB" stroke-width="1"/> <polyline points="14 2 14 8 20 8" fill="#CCCCCC" stroke="#AAA" stroke-width="0.5"/> <g class="magnifier-group"> <circle cx="11.5" cy="14.5" r="3" fill="#F0F0F0" stroke="#777" stroke-width="1"/> <circle class="magnifying-lens" cx="11.5" cy="14.5" r="2.5" fill="rgba(180, 220, 255, 0.7)" stroke="rgba(100, 150, 200, 0.8)" stroke-width="0.5"/> <path class="magnifying-handle" d="M13.5 16.5 C15 17.5 17 19.5 19 20.5 Q19.5 21 19 21.5 C17 20.5 15 18.5 13.5 17.5 Z" fill="#999999" stroke="#777" stroke-width="0.5"/> </g> </svg> <span class="text-slate-800">آپلود فایل</span> </button> </div> <div id="image-gallery-modal" class="fixed inset-0 z-[70] hidden items-center justify-center p-4 bg-black/80 backdrop-blur-sm"> <div id="image-gallery-content" class="relative flex flex-col w-full h-full max-w-5xl max-h-[90vh] transition-all duration-300 scale-95 opacity-0"> <button id="gallery-close-btn" class="absolute top-2 left-2 z-10 p-2 text-white bg-black/30 rounded-full hover:bg-black/60 transition-colors"> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg> </button> <div class="relative flex-grow flex items-center justify-center overflow-hidden rounded-lg"> <img id="gallery-main-image" src="" alt="تمام صفحه" class="object-contain max-w-full max-h-full transition-opacity duration-300"> <button id="gallery-prev-btn" class="absolute left-4 p-3 text-white bg-black/30 rounded-full hover:bg-black/60 transition-colors"> <svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> </button> <button id="gallery-next-btn" class="absolute right-4 p-3 text-white bg-black/30 rounded-full hover:bg-black/60 transition-colors"> <svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> </button> </div> <div class="flex-shrink-0 pt-4 flex flex-col items-center justify-center gap-4"> <button id="gallery-download-btn" class="flex-shrink-0 flex items-center justify-center gap-2 text-white font-semibold"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg> <span>دانلود</span> </button> <div id="gallery-thumbnails" class="w-full flex items-center justify-center gap-2 overflow-x-auto custom-scrollbar p-2"> </div> </div> </div> </div> <script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
@keyframes fade-slide-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.message-entry { animation: fade-slide-in 300ms ease-out forwards; }
.typing-indicator span { height: 8px; width: 8px; background-color: #9ca3af; border-radius: 50%; display: inline-block; animation: typing-blink 1.4s infinite both; }
.dark .typing-indicator span { background-color: #6b7280; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-blink { 0% { opacity: 0.2; } 20% { opacity: 1; } 100% { opacity: 0.2; } }
.prose pre {
position: relative;
background-color: #282c34;
border-radius: 0.75rem;
padding-top: 2.5rem;
overflow-x: auto;
}
.prose pre code {
white-space: pre;
word-break: normal;
display: block;
padding: 0.5rem 0.75rem;
direction: ltr;
text-align: left;
}
.code-button-container {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.2s;
}
.prose pre:hover .code-button-container { opacity: 1; }
.code-button {
padding: 0.25rem 0.5rem;
background-color: rgba(75, 85, 99, 0.8);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
transition: background-color 0.2s;
}
.code-button:hover { background-color: rgba(107, 114, 128, 0.8); }
.code-button:active { background-color: #4b5563; }
.message-actions { opacity: 0; transition: opacity 0.3s ease-in-out; position: absolute; bottom: -1rem; left: 0; display: flex; align-items: center; }
.group:hover .message-actions { opacity: 1; }
.action-button { padding: 0.375rem; border-radius: 9999px; background-color: rgba(255, 255, 255, 0.7); backdrop-filter: blur(4px); border: 1px solid #e5e7eb; color: #6b7280; transition: all 0.2s; box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
.dark .action-button { background-color: rgba(55, 65, 81, 0.7); border-color: #4b5563; color: #9ca3af; }
.action-button:hover { color: #1f2937; background-color: rgba(243, 244, 246, 0.9); border-color: #d1d5db; }
.dark .action-button:hover { color: #e5e7eb; background-color: rgba(75, 85, 99, 0.9); border-color: #6b7280; }
.action-button.active { color: #3b82f6; border-color: #93c5fd; background-color: #eff6ff; }
.dark .action-button.active { color: #60a5fa; border-color: #3b82f6; background-color: #1e3a8a; }
.like-animation { animation: like-pop 0.4s ease-in-out; }
@keyframes like-pop { 0% { transform: scale(1); } 50% { transform: scale(1.4); } 100% { transform: scale(1); } }
.action-button .copy-feedback {
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
background-color: #1f2937;
color: white;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
pointer-events: none;
}
.action-button .copy-feedback.visible {
opacity: 1;
transform: translateY(-50%) translateX(-4px);
}
.history-item-button { opacity: 1; }
#history-item-menu { transform-origin: top right; transform: scale(0.95); opacity: 0; pointer-events: none; transition: transform 150ms ease-out, opacity 150ms ease-out; }
#history-item-menu.visible { transform: scale(1); opacity: 1; pointer-events: auto; }
#message-item-menu { transition: opacity 200ms ease-in-out; }
#message-item-menu-content { transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); transform: scale(0.95); opacity: 0; }
#message-item-menu.visible { opacity: 1; }
#message-item-menu.visible #message-item-menu-content { transform: scale(1); opacity: 1; }
.message-preview-container { padding: 0.75rem 1rem; border-bottom: 1px solid #e5e7eb; margin-bottom: 0.5rem; }
.dark .message-preview-container { border-bottom-color: #374151; }
.message-preview-text { font-size: 0.875rem; color: #6b7280; line-height: 1.5; max-height: 4.5rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; text-overflow: ellipsis; }
.dark .message-preview-text { color: #d1d5db; }
#confirm-modal-content, #rename-modal-content, #edit-modal-content, #html-preview-content { transform: scale(0.95); opacity: 0; }
#message-input, #edit-input { resize: none; overflow-y: auto; max-height: 120px; line-height: 1.5; padding-top: 10px; padding-bottom: 10px; }
#message-form { align-items: flex-end; transition: all 0.2s, border-radius 0.15s ease-out; }
#app-container { display: flex; flex-direction: column; }
#main-header, #main-footer { position: static; flex-shrink: 0; }
#chat-window { flex-grow: 1; }
#html-preview-modal { position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px); display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 60; }
#html-preview-content { background-color: #fff; border-radius: 1rem; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); width: 100%; height: 90%; max-width: 800px; display: flex; flex-direction: column; overflow: hidden; transform: scale(0.95); opacity: 0; }
#html-preview-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #e2e8f0; background-color: #f8fafc; }
.dark #html-preview-header { border-bottom-color: #374151; background-color: #1f2937; }
#html-preview-iframe-container { flex-grow: 1; overflow: hidden; }
#html-preview-iframe { width: 100%; height: 100%; border: none; background-color: #fff; }
.message-entry p.whitespace-pre-wrap {
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}
.prose {
overflow-wrap: break-word;
word-break: break-word;
}
.dark .prose { --tw-prose-body: #d1d5db; --tw-prose-headings: #fff; --tw-prose-lead: #9ca3af; --tw-prose-links: #fff; --tw-prose-bold: #fff; --tw-prose-counters: #9ca3af; --tw-prose-bullets: #4b5563; --tw-prose-hr: #374151; --tw-prose-quotes: #f3f4f6; --tw-prose-quote-borders: #374151; --tw-prose-captions: #9ca3af; --tw-prose-code: #fff; --tw-prose-pre-code: #d1d5db; --tw-prose-pre-bg: #1f2937; --tw-prose-th-borders: #4b5563; --tw-prose-td-borders: #374151; }
.prose table {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
margin-bottom: 1em;
display: block;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
min-width: 100%;
table-layout: auto;
}
.prose th,
.prose td {
border: 1px solid #e2e8f0;
padding: 0.75em 0.5em;
text-align: right;
vertical-align: top;
white-space: normal;
overflow-wrap: break-word;
word-break: normal;
}
.dark .prose th, .dark .prose td { border-color: #4b5563; }
.prose th {
background-color: #f8fafc;
font-weight: 600;
color: #334155;
}
.dark .prose th { background-color: #374151; color: #d1d5db; }
.prose tr:nth-child(even) {
background-color: #fcfdfe;
}
.dark .prose tr:nth-child(even) { background-color: #1f2937; }
@media (max-width: 768px) {
.prose th,
.prose td {
font-size: 0.85rem;
padding: 0.6em 0.4em;
}
}
#file-popup-menu {
position: absolute;
background-color: white;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
padding: 0.5rem;
z-index: 50;
width: auto;
opacity: 0;
transform: translateY(10px) scale(0.95);
transform-origin: bottom left;
transition: opacity 200ms ease-out, transform 200ms ease-out;
pointer-events: none;
min-width: 200px;
}
#file-popup-menu.visible {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.popup-option {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
width: 100%;
text-align: right;
border-radius: 0.5rem;
transition: background-color 0.2s;
color: #333;
font-size: 16px;
}
.popup-option:hover {
background-color: #f3f4f6;
}
.dark .popup-option:hover { background-color: #374151; }
.popup-option svg {
width: 32px;
height: 32px;
margin-right: 15px;
flex-shrink: 0;
filter: drop-shadow(0px 2px 3px rgba(0,0,0,0.2));
transition: transform 0.3s ease, filter 0.3s ease;
}
.popup-option:hover svg {
transform: translateY(-3px) scale(1.05);
filter: drop-shadow(0px 4px 6px rgba(0,0,0,0.3));
}
.popup-option .upload-arrow {
animation: uploadPulse 1.5s infinite alternate ease-in-out;
}
@keyframes uploadPulse {
0% { transform: translateY(0); opacity: 1; }
50% { transform: translateY(-3px); opacity: 0.8; }
100% { transform: translateY(0); opacity: 1; }
}
.popup-option .magnifier-group {
transform-origin: 12px 14px;
animation: magnifierFloatScan 3s infinite alternate ease-in-out;
}
@keyframes magnifierFloatScan {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(1px, -1px) scale(1.02); }
66% { transform: translate(-1px, 1px) scale(1.02); }
100% { transform: translate(0px, 0px) scale(1); }
}
.popup-option .magnifying-lens {
animation: lensGlow 2s infinite alternate ease-in-out;
}
@keyframes lensGlow {
0% { fill: rgba(180, 220, 255, 0.7); }
50% { fill: rgba(220, 240, 255, 1); }
100% { fill: rgba(180, 220, 255, 0.7); }
}
#image-preview-container {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
width: fit-content;
}
#image-preview-container.hidden { display: none !important; }
#file-info-text {
font-size: 0.875rem;
color: #475569;
max-width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.welcome-screen {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 20px;
overflow-x: hidden;
box-sizing: border-box;
background-color: #f8f9fa;
color: #333;
}
.dark .welcome-screen { background-color: #111827; color: #d1d5db; }
.welcome-container {
max-width: 500px;
width: 100%;
text-align: center;
}
.chatbot-name {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(45deg, #0ea5e9, #6f42c1, #fd7e14);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
display: inline-block;
line-height: 1.2;
opacity: 0;
transition: opacity 0.5s ease-in-out;
animation: gradient-flow 8s linear infinite alternate;
}
@keyframes gradient-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.main-title {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 3rem;
color: #2c3e50;
line-height: 1.5;
opacity: 0;
transition: opacity 0.8s ease-in;
}
.dark .main-title { color: #e5e7eb; }
.buttons-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 2rem;
}
.action-button-welcome {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 1.5rem 1.5rem;
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 25px;
color: #495057;
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
min-height: 70px;
opacity: 0;
transform: translateY(20px);
}
.dark .action-button-welcome { background: #1f2937; border-color: #374151; color: #d1d5db; }
.action-button-welcome:hover {
background: #f8f9fa;
border-color: #dee2e6;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.dark .action-button-welcome:hover { background: #374151; border-color: #4b5563; }
.button-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bold;
}
.orange-icon { background: #fd7e14; color: white; }
.green-icon { background: #20c997; color: white; }
.purple-icon { background: #6f42c1; color: white; }
.blue-icon { background: #0ea5e9; color: white; }
.button-text {
text-align: right;
line-height: 1.3;
flex: 1;
margin-right: 1rem;
}
#submit-button {
position: relative;
}
#submit-button svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
#stop-icon {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5);
}
#submit-button.is-loading #send-icon {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5);
}
#submit-button.is-loading #stop-icon {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
@media (max-width: 480px) {
.chatbot-name, .main-title { font-size: 1.5rem; }
.action-button-welcome { padding: 1.2rem; font-size: 0.85rem; min-height: 60px; }
.button-icon { width: 36px; height: 36px; font-size: 1.2rem; }
}
#image-gallery-modal {
transition: opacity 300ms ease-in-out;
}
#image-gallery-modal.hidden {
pointer-events: none;
}
#image-gallery-modal.visible {
opacity: 1;
}
#image-gallery-modal.visible #image-gallery-content {
opacity: 1;
transform: scale(1);
}
#gallery-thumbnails .gallery-thumb {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
border: 3px solid transparent;
transition: all 0.2s ease-in-out;
opacity: 0.6;
}
#gallery-thumbnails .gallery-thumb:hover {
opacity: 1;
transform: scale(1.05);
}
#gallery-thumbnails .gallery-thumb.active {
border-color: #3b82f6;
opacity: 1;
transform: scale(1.1);
}
#gallery-download-btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 9999px;
background: linear-gradient(45deg, #3b82f6, #6366f1);
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
transition: all 0.3s ease-in-out;
cursor: pointer;
min-width: 150px;
min-height: 48px;
animation: download-pulse 2.5s infinite;
}
#gallery-download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 rgba(59, 130, 246, 0.45);
animation-play-state: paused;
}
#gallery-download-btn:active {
transform: translateY(0px) scale(0.98);
box-shadow: 0 2px 8px 0 rgba(59, 130, 246, 0.3);
}
#gallery-download-btn .typing-indicator span {
background-color: white;
}
@keyframes download-pulse {
0% {
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
}
50% {
box-shadow: 0 6px 20px 0 rgba(99, 102, 241, 0.5);
}
100% {
box-shadow: 0 4px 14px 0 rgba(59, 130, 246, 0.39);
}
}
@keyframes fade-slide-in-button {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.clarification-message {
background-color: #f8fafc;
border: 1px solid #e2e8f0;
color: #1e293b;
}
.dark .clarification-message { background-color: #1f2937; border-color: #374151; color: #d1d5db; }
.clarification-button.glass-button {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 12px 16px;
font-size: 0.95rem;
font-weight: 500;
color: #334155;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(200, 200, 200, 0.4);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
animation: fade-slide-in-button 0.5s ease-out forwards;
opacity: 0;
}
.dark .clarification-button.glass-button { color: #e5e7eb; background: rgba(55, 65, 81, 0.5); border-color: rgba(107, 114, 128, 0.4); }
.clarification-button.glass-button:nth-of-type(1) {
animation-delay: 0.1s;
}
.clarification-button.glass-button:nth-of-type(2) {
animation-delay: 0.2s;
}
.clarification-button.glass-button:hover {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(107, 114, 128, 0.5);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.07);
}
.dark .clarification-button.glass-button:hover { background: rgba(75, 85, 99, 0.8); border-color: rgba(156, 163, 175, 0.5); }
.clarification-button.glass-button:active {
transform: translateY(0px) scale(0.98);
box-shadow: none;
}
.clarification-button.glass-button svg {
flex-shrink: 0;
color: #4f46e5;
}
.dark .clarification-button.glass-button svg { color: #818cf8; }
.menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: #374151;
border-radius: 0.375rem;
transition: background-color 0.2s;
cursor: pointer;
width: 100%;
}
.dark .menu-item { color: #d1d5db; }
.menu-item:hover {
background-color: #f3f4f6;
}
.dark .menu-item:hover { background-color: #374151; }
.menu-item.danger:hover {
background-color: #fee2e2;
color: #b91c1c;
}
.dark .menu-item.danger:hover { background-color: #450a0a; color: #f87171; }
.menu-item svg {
width: 1.25rem;
height: 1.25rem;
}
.menu-divider {
height: 1px;
background-color: #e5e7eb;
margin: 0.5rem 0;
}
.dark .menu-divider { background-color: #374151; }
.sidebar-button {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
width: 100%;
text-align: right;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
color: #4b5563;
transition: background-color 0.2s, color 0.2s;
}
.dark .sidebar-button { color: #d1d5db; }
.sidebar-button:hover { background-color: #e5e7eb; color: #1f2937; }
.dark .sidebar-button:hover { background-color: #374151; color: #f9fafb; }
.sidebar-button svg { flex-shrink: 0; }
.theme-switch {
position: relative;
display: inline-block;
width: 50px;
height: 28px;
}
.theme-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 28px;
}
.dark .slider { background-color: #374151; }
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4f46e5;
}
input:checked + .slider:before {
transform: translateX(22px);
} این یکی import * as state from './state.js';
import * as ui from './ui.js';
const UPLOADER_API_URL = 'https://asrasahar-ok-uploader-bot.hf.space/upload';
const YOUR_IMAGE_UPLOAD_SERVER_URL = 'https://www.aisada.ir/hamed/upload.php';
const CONVERTER_API_URL = 'https://texttopdf-5irq.onrender.com/';
const HF_IMAGE_GEN_URL_BASE = "https://black-forest-labs-flux-1-schnell.hf.space";
const FN_INDEX_GEN_IMAGE = 2;
const TRIGGER_ID_GEN_IMAGE = 5;
function getDimensionsFromRatio(ratioString) {
const defaultSize = { width: 768, height: 1344 };
if (!ratioString || typeof ratioString !== 'string') return defaultSize;
const r = ratioString.toLowerCase().replace(/[\sدر:بهx]/g, '');
if (r === '11' || r.includes('مربع')) return { width: 1024, height: 1024 };
if (r === '169' || r.includes('افقی') || r.includes('لندسکیپ')) return { width: 1344, height: 768 };
if (r === '916' || r.includes('عمودی') || r.includes('پرتره') || r.includes('موبایل')) return { width: 768, height: 1344 };
if (r === '43') return { width: 1152, height: 864 };
if (r === '34') return { width: 864, height: 1152 };
if (r === '32') return { width: 1216, height: 832 };
if (r === '23') return { width: 832, height: 1216 };
console.warn(نسبت تصویر '${ratioString}' شناسایی نشد. از اندازه پیشفرض استفاده میشود.
);
return defaultSize;
}
export async function convertTextToFile(content, format, buttonElement) {
if (buttonElement) ui.showLoadingOnButton(buttonElement, true);
try {
const convertFormData = new FormData();
convertFormData.append('content', content);
convertFormData.append('format', format);
const convertResponse = await fetch(CONVERTER_API_URL, {
method: 'POST',
body: convertFormData,
});
if (!convertResponse.ok) {
throw new Error(خطا در ارتباط با سرور تبدیل: ${convertResponse.statusText}
);
}
const fileBlob = await convertResponse.blob();
const fileName = alpha-export-${Date.now()}.${format}
;
const uploadFormData = new FormData();
uploadFormData.append('image', fileBlob, fileName);
const uploadResponse = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, {
method: 'POST',
body: uploadFormData,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text().catch(() => HTTP ${uploadResponse.status}
);
throw new Error(آپلود فایل ساخته شده به سرور شما ناموفق بود: ${errorText}
);
}
const uploadData = await uploadResponse.json();
if (uploadData.success && uploadData.url) {
window.parent.postMessage({
type: 'OPEN_EXTERNAL_URL',
url: uploadData.url
}, '*');
} else {
throw new Error(uploadData.message || 'پاسخ سرور آپلود شما پس از ساخت فایل، نامعتبر بود.');
}
} catch (error) {
console.error('خطا در فرآیند تبدیل و آپلود فایل:', error);
alert(متاسفانه در آمادهسازی فایل برای دانلود خطایی رخ داد: ${error.message}
);
} finally {
if (buttonElement) ui.showLoadingOnButton(buttonElement, false);
}
}
// #############################################################################
// START: کد نهایی و اصلاح شده
// این تابع بازنویسی شده تا به صورت هوشمند به مدل یادآوری کند که با چه نوع فایلی روبروست
// #############################################################################
function summarizeHistoryForApi(history) {
return history.map(msg => {
if (msg.isTemporary) return null;
textconst newMsg = { ...msg }; const newParts = []; let hasContent = false; if (newMsg.parts && newMsg.parts.length > 0) { for (const part of newMsg.parts) { const newPart = { ...part }; delete newPart.base64Data; delete newPart.inlineData; // **بخش کلیدی و اصلاح شده** // اگر پارت حاوی فایل باشد، یک دستور صریح برای تحلیل آن اضافه میکنیم if (part.fileUrl && part.mimeType) { let fileTypeDescription = "این فایل"; if (part.mimeType.startsWith('video/')) { fileTypeDescription = "این ویدیو"; } else if (part.mimeType.startsWith('audio/')) { fileTypeDescription = "این فایل صوتی"; } else if (part.mimeType.startsWith('image/')) { // برای عکس نیازی به متن اضافه نیست } else { fileTypeDescription = `این سند (${part.name})`; } // فقط اگر از نوع عکس نباشد متن را اضافه کن if (!part.mimeType.startsWith('image/')) { // اگر پیام کاربر متنی نداشت، یک پارت متنی جدید ایجاد کن let textPart = newParts.find(p => p.text); const instructionText = `\n(دستور برای تو: ${fileTypeDescription} را تحلیل کن و محتوای آن را شرح بده.)`; if (textPart) { textPart.text += instructionText; } else { newParts.push({ text: instructionText }); } } } // فقط پارتهایی که محتوای واقعی دارند را نگه دار if (newPart.text || newPart.fileUrl || newPart.image_url || newPart.edited_images) { newParts.push(newPart); hasContent = true; } } } // یادآوری پرامپت استفاده شده برای ساخت تصویر if (msg.role === 'user' && msg.english_prompt_used) { const prompt_note = `پرامپت انگلیسی استفاده شده: "${msg.english_prompt_used}"`; const ratio_note = msg.aspect_ratio_used ? `, نسبت تصویر: "${msg.aspect_ratio_used}"` : ""; const memoryNoteText = `\n[یادآوری برای تو (مدل): این پیام کاربر منجر به ساخت تصویری با مشخصات زیر شد. ${prompt_note}${ratio_note}]`; let textPart = newParts.find(p => p.text); if (textPart) { textPart.text += memoryNoteText; } else { newParts.push({ text: memoryNoteText }); } hasContent = true; } newMsg.parts = newParts; return hasContent ? newMsg : null; }).filter(Boolean);
}
// #############################################################################
// END: کد نهایی و اصلاح شده
// #############################################################################
function uploadFileToHuggingFace(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
state.setCurrentUploadXHR(xhr);
xhr.open('POST', UPLOADER_API_URL, true);
xhr.onload = function () {
state.setCurrentUploadXHR(null);
try {
const response = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
if (response.hf_url) {
resolve(response);
} else {
reject(new Error("پاسخ سرور فاقد 'hf_url' بود."));
}
} else {
reject(new Error(response.error || خطای سرور: ${xhr.statusText}
));
}
} catch (e) {
reject(new Error(خطای ناشناخته در پاسخ سرور آپلود. ${xhr.statusText}
));
}
};
xhr.onerror = function () {
state.setCurrentUploadXHR(null);
if (xhr.status === 0 && !xhr.statusText) {
reject(new Error('آپلود توسط کاربر لغو شد.'));
} else {
reject(new Error('خطای ارتباط با شبکه هنگام آپلود. لطفاً اتصال اینترنت خود را بررسی کنید.'));
}
};
xhr.upload.onprogress = function (event) {
if (event.lengthComputable && onProgress) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
onProgress(percentComplete);
}
};
const formData = new FormData();
formData.append('file', file);
xhr.send(formData);
});
}
async function uploadUrlToHuggingFace(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', UPLOADER_API_URL, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
try {
const response = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && response.hf_url) {
resolve(response);
} else {
reject(new Error(response.error || خطای سرور: ${xhr.statusText}
));
}
} catch (e) {
reject(new Error(خطای ناشناخته در پاسخ سرور آپلود. ${xhr.statusText}
));
}
};
xhr.onerror = function () { reject(new Error('خطای ارتباط با شبکه هنگام آپلود URL.')); };
xhr.send(JSON.stringify({ url: url }));
});
}
export async function processAndUploadFile(file, onProgress) {
const readFileAsBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result.split(',')[1];
resolve(base64String);
};
reader.onerror = (error) => reject(error);
reader.readAsDataURL(file);
});
};
try {
const [uploadResult, base64Data] = await Promise.all([
uploadFileToHuggingFace(file, onProgress),
readFileAsBase64(file)
]);
return {
url: uploadResult.hf_url,
name: file.name,
mimeType: file.type,
base64Data: base64Data
};
} catch (error) {
console.error("خطا در پردازش و آپلود فایل:", error);
throw error;
}
}
export async function uploadToAISADAAndOpenAlpha(imageUrlFromSpace, downloadButtonElement) {
if (!imageUrlFromSpace) {
if (downloadButtonElement) downloadButtonElement.textContent = "خطا: URL نامعتبر";
return;
}
if (downloadButtonElement) {
downloadButtonElement.disabled = true;
downloadButtonElement.innerHTML = <div class="typing-indicator" style="height:16px; gap: 3px;"><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span></div>
;
}
try {
const imageResponse = await fetch(imageUrlFromSpace);
if (!imageResponse.ok) throw new Error(خطا در دریافت فایل از هاگینگ فیس: ${imageResponse.status}
);
const imageBlob = await imageResponse.blob();
const formData = new FormData();
let filename = generated_alpha_image_${Date.now()}.webp
;
try {
const urlPath = new URL(imageUrlFromSpace).pathname.split('/').pop();
if(urlPath && urlPath.includes('.')) filename = urlPath.split('?')[0];
} catch(e){ console.warn("Could not parse filename from HF URL:", e); }
formData.append('image', imageBlob, filename);
const uploadResponse = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: formData });
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text().catch(() => HTTP ${uploadResponse.status}
);
throw new Error(آپلود به سرور شما ناموفق بود: ${errorText}
);
}
const uploadData = await uploadResponse.json();
if (uploadData.success && uploadData.url) {
window.parent.postMessage({
type: 'OPEN_EXTERNAL_URL',
url: uploadData.url
}, '*');
} else {
throw new Error(uploadData.message || 'پاسخ سرور آپلود شما نامعتبر بود.');
}
} catch (error) {
console.error("خطا در فرآیند دانلود و آپلود تصویر:", error);
if (downloadButtonElement) downloadButtonElement.textContent = "خطا";
alert(خطا در آمادهسازی دانلود: ${error.message}
);
} finally {
if (downloadButtonElement) {
setTimeout(() => {
downloadButtonElement.disabled = false;
downloadButtonElement.innerHTML = <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
;
}, 2000);
}
}
}
function convertImageBlobToPng(imageBlob) {
return new Promise((resolve, reject) => {
const imageUrl = URL.createObjectURL(imageBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
resolve(blob);
URL.revokeObjectURL(imageUrl);
}, 'image/png');
};
img.onerror = () => {
reject(new Error('خطا در بارگذاری تصویر برای تبدیل فرمت.'));
URL.revokeObjectURL(imageUrl);
};
img.src = imageUrl;
});
}
export async function runExternalImageEditor(prompt, fileUrl, modelBubbleOuterDivElement, messageIndex) {
const activeChat = state.getActiveChat();
const RENDER_API_URL = 'https://alfa-editor-worker.onrender.com/api/edit';
textconst showError = (error) => { ui.displayError(modelBubbleOuterDivElement, error.message || String(error)); }; try { if (!fileUrl) throw new Error("آدرس فایلی برای ویرایش یافت نشد."); ui.showStatusUpdate("در حال دانلود فایل برای ویرایش...", modelBubbleOuterDivElement); const response = await fetch(fileUrl); if (!response.ok) throw new Error(`خطا در دانلود فایل برای ویرایش: ${response.statusText}`); let imageBlob = await response.blob(); const originalMimeType = imageBlob.type || response.headers.get('content-type'); if (originalMimeType && originalMimeType !== 'image/png' && originalMimeType !== 'image/jpeg') { const format = originalMimeType.split('/')[1] || 'ناشناخته'; ui.showStatusUpdate(`فرمت تصویر (${format.toUpperCase()}) نامناسب است. در حال تبدیل به PNG...`, modelBubbleOuterDivElement); imageBlob = await convertImageBlobToPng(imageBlob); } ui.showStatusUpdate("ارسال درخواست به سرور ویرایش...", modelBubbleOuterDivElement); const formData = new FormData(); formData.append("image", imageBlob, 'image.png'); formData.append("prompt", prompt); const editResponse = await fetch(RENDER_API_URL, { method: 'POST', body: formData, signal: state.globalAbortController.signal }); const result = await editResponse.json(); if (!editResponse.ok) throw new Error(result.error || `خطای سرور ویرایش: ${editResponse.status}`); if (!result.image_urls || result.image_urls.length === 0) { throw new Error("پردازش کامل شد اما تصویری از سرور ویرایش دریافت نشد."); } ui.showStatusUpdate("در حال دائمیسازی لینکهای تصاویر ویرایش شده...", modelBubbleOuterDivElement); const permanentUrls = await Promise.all( result.image_urls.map(tempUrl => uploadUrlToHuggingFace(tempUrl).then(res => res.hf_url)) ); const finalMessage = { role: 'model', parts: [{ edited_images: permanentUrls }] }; if (typeof messageIndex !== 'undefined') { activeChat.messages[messageIndex] = finalMessage; state.saveSessions(); ui.addMessageToUI(finalMessage, messageIndex, { isLastModel: true, animate: false }, modelBubbleOuterDivElement); } } catch (error) { console.error("خطا در فرآیند ویرایش تصویر:", error); if (error.name !== 'AbortError') showError(error); else modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات ویرایش متوقف شد --</p>'; } finally { ui.resetState(); }
}
export async function runExternalImageGenerator(englishPrompt, aspectRatio, modelBubbleOuterDivElement, followUpText = null) {
const activeChat = state.getActiveChat();
const sessionHash = Math.random().toString(36).substring(2, 15);
textconst showError = (error) => { const errorMessage = error.message || String(error); if (errorMessage.toLowerCase().includes("gpu") || errorMessage.toLowerCase().includes("quota")) { ui.displayError(modelBubbleOuterDivElement, errorMessage, 'gpu_quota_exceeded'); } else { ui.displayError(modelBubbleOuterDivElement, errorMessage); } }; try { const dimensions = getDimensionsFromRatio(aspectRatio); ui.showStatusUpdate("ارسال درخواست به سرور تصویر...", modelBubbleOuterDivElement); const payload = [ englishPrompt, Math.floor(Math.random() * 2147483647), true, dimensions.width, dimensions.height, 4 ]; const joinPayload = { "fn_index": FN_INDEX_GEN_IMAGE, "data": payload, "event_data": null, "session_hash": sessionHash, "trigger_id": TRIGGER_ID_GEN_IMAGE }; const joinResponse = await fetch(`${HF_IMAGE_GEN_URL_BASE}/queue/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(joinPayload), signal: state.globalAbortController.signal }); if (!joinResponse.ok) { let errorText = `خطای سرور: ${joinResponse.status}`; try { const errorJson = await joinResponse.json(); errorText = errorJson.error || errorJson.detail || errorText; } catch(e) {} throw new Error(errorText); } const joinResult = await joinResponse.json(); if (!joinResult.event_id) throw new Error("event_id از Gradio دریافت نشد."); if (state.currentImageEventSource) { state.currentImageEventSource.close(); } const eventSource = new EventSource(`${HF_IMAGE_GEN_URL_BASE}/queue/data?session_hash=${sessionHash}`); state.setCurrentImageEventSource(eventSource); await new Promise((resolve, reject) => { eventSource.onmessage = async (event) => { const data = JSON.parse(event.data); switch(data.msg) { case "process_starts": ui.showStatusUpdate("پردازش تصویر در سرور آغاز شد...", modelBubbleOuterDivElement); break; case "progress": if (data.progress_data && data.progress_data.length > 0) { const p = data.progress_data[0]; ui.showStatusUpdate(`در حال ساخت... (${p.index}/${p.length})`, modelBubbleOuterDivElement); } break; case "process_completed": eventSource.close(); state.setCurrentImageEventSource(null); if (data.success && data.output?.data?.[0]?.url) { const tempImageUrl = data.output.data[0].url; ui.showStatusUpdate("تصویر ساخته شد. در حال دائمیسازی لینک...", modelBubbleOuterDivElement); try { const uploadResult = await uploadUrlToHuggingFace(tempImageUrl); const permanentUrl = uploadResult.hf_url; const newModelMessageParts = [{ image_url: permanentUrl }]; if (followUpText) { newModelMessageParts.push({ text: followUpText }); } const newModelMessage = { role: 'model', parts: newModelMessageParts }; activeChat.messages[activeChat.messages.length - 1] = newModelMessage; state.saveSessions(); ui.addMessageToUI(newModelMessage, activeChat.messages.length - 1, { isLastModel: true, animate: false }, modelBubbleOuterDivElement); resolve(); } catch (uploadError) { reject(new Error(`تصویر ساخته شد اما در آپلود به سرور دائمی خطا رخ داد: ${uploadError.message}`)); } } else { reject(new Error(data.output?.error || "پاسخ سرور ساخت تصویر، فاقد URL معتبر بود.")); } break; case 'queue_full': eventSource.close(); reject(new Error("صف پردازش تصویر پر است. لطفاً چند لحظه دیگر تلاش کنید.")); break; } }; eventSource.onerror = () => { eventSource.close(); state.setCurrentImageEventSource(null); reject(new Error("ارتباط با سرور ساخت تصویر قطع شد.")); }; }); } catch (error) { if (error.name !== 'AbortError') { showError(error); } else { modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- تولید پاسخ متوقف شد --</p>'; } } finally { ui.resetState(); }
}
export async function streamResponse(modelBubbleOuterDivElement, incomingHistory, chatId, actionPayload = null) {
state.setGlobalAbortController(new AbortController());
let fullResponseText = "";
const activeChat = state.getActiveChat();
let finalMessageObject = { role: 'model', parts: [] };
let bodyPayload;
try {
const historyForApi = summarizeHistoryForApi(actionPayload ? incomingHistory.slice(0, -1) : incomingHistory);
bodyPayload = {
history: historyForApi,
model: state.getActiveChat().model,
chatId: chatId,
action: actionPayload
};
if (!actionPayload) {
const lastUserMessage = incomingHistory[incomingHistory.length - 2];
const filePart = lastUserMessage?.parts.find(p => p.base64Data);
if (filePart) {
const apiMessageToUpdate = bodyPayload.history[bodyPayload.history.length - 1];
if (apiMessageToUpdate) {
apiMessageToUpdate.parts.unshift({
inlineData: {
mimeType: filePart.mimeType,
data: filePart.base64Data
}
});
}
}
}
} catch(error) {
ui.displayError(modelBubbleOuterDivElement, خطا در آمادهسازی درخواست: ${error.message}
);
ui.resetState();
return;
}
try {
const response = await fetch('/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: state.globalAbortController.signal,
body: JSON.stringify(bodyPayload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({error: {message: خطای سرور: ${response.statusText}
}}));
throw new Error(errorData.error.message || خطای سرور: ${response.status}
);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.substring(6);
if (jsonStr.trim() === '') continue;
const data = JSON.parse(jsonStr);
const lastUserMessage = activeChat.messages[activeChat.messages.length - 2];
if (data.intent === 'generate_image') {
reader.cancel();
if(lastUserMessage && lastUserMessage.role === 'user') {
lastUserMessage.english_prompt_used = data.english_prompt;
lastUserMessage.aspect_ratio_used = data.aspect_ratio;
state.saveSessions();
}
await runExternalImageGenerator(data.english_prompt, data.aspect_ratio, modelBubbleOuterDivElement);
return;
}
else if (data.intent === 'generate_image_with_text') {
reader.cancel();
const { text, image_generation_payload, follow_up_text } = data;
if (lastUserMessage && lastUserMessage.role === 'user') {
lastUserMessage.english_prompt_used = image_generation_payload.english_prompt;
lastUserMessage.aspect_ratio_used = image_generation_payload.aspect_ratio;
state.saveSessions();
}
ui.streamFinalText(text, modelBubbleOuterDivElement);
await runExternalImageGenerator(
image_generation_payload.english_prompt,
image_generation_payload.aspect_ratio,
modelBubbleOuterDivElement,
follow_up_text
);
return;
}
else if (data.intent === 'clarify_action') {
reader.cancel();
const commandMessage = { role: 'model', clarification: data.options, question: data.question, parts: [] };
activeChat.messages[activeChat.messages.length - 1] = commandMessage;
state.saveSessions();
ui.addMessageToUI(commandMessage, activeChat.messages.length - 1, { isLastModel: true }, modelBubbleOuterDivElement);
ui.resetState();
return;
}
else if (data.intent === 'edit_image') {
reader.cancel();
let fileUrlForEditing = null;
for (let i = incomingHistory.length - 2; i >= 0; i--) {
const msg = incomingHistory[i];
if (msg.parts) {
const imagePart = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0));
if(imagePart) {
fileUrlForEditing = imagePart.image_url || imagePart.edited_images[0];
break;
}
const filePart = msg.parts.find(p => p.fileUrl);
if(filePart) {
fileUrlForEditing = filePart.fileUrl;
break;
}
}
}
if (!fileUrlForEditing) {
throw new Error("قصد ویرایش تشخیص داده شد اما هیچ فایلی برای ویرایش یافت نشد.");
}
await runExternalImageEditor(data.prompt, fileUrlForEditing, modelBubbleOuterDivElement, activeChat.messages.length - 1);
return;
}
else if (data.error) {
throw new Error(data.error.message || JSON.stringify(data.error));
}
else if (data.candidates) {
const chunkText = data.candidates[0].content.parts[0].text;
fullResponseText += chunkText;
ui.streamFinalText(fullResponseText, modelBubbleOuterDivElement, false);
}
} catch (e) { console.warn("خطا در پردازش SSE chunk:", e, "Chunk:", line); }
}
}
}
if (fullResponseText) {
finalMessageObject.parts.push({ text: fullResponseText });
}
if (finalMessageObject.parts.length > 0) {
activeChat.messages[activeChat.messages.length - 1] = finalMessageObject;
} else {
activeChat.messages.pop();
}
state.saveSessions();
ui.updateMessageActions(modelBubbleOuterDivElement, finalMessageObject, false, true);
} catch (error) {
if (error.name === 'AbortError') {
modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات متوقف شد --</p>';
} else {
ui.displayError(modelBubbleOuterDivElement, error.message || 'یک خطای ناشناخته رخ داد.');
}
} finally {
ui.resetState();
}
} این یکی import * as state from './state.js';
import * as ui from './ui.js';
import * as api from './api.js';
async function handleFileSelection(event) {
const file = event.target.files[0];
if (!file) return;
textui.showFileUploading(file.name); ui.dom.submitButton.disabled = true; try { const onProgress = (percent) => { ui.updateUploadProgress(percent); }; const uploadedFileData = await api.processAndUploadFile(file, onProgress); state.setAttachedFile(uploadedFileData); ui.showFileReady(file.name, file.type, uploadedFileData.url); } catch (error) { console.error("خطا در آپلود فایل:", error); if (error.message !== 'آپلود توسط کاربر لغو شد.') { ui.showFileError(error.message); } else { ui.hideFilePreview(); } } finally { event.target.value = ''; ui.toggleFilePopupMenu(false); ui.dom.submitButton.disabled = false; }
}
function handleNewChat() {
// #####################################################################
// START: کد اصلاح شده
// مدل پیشفرض به نسخه قدرتمندتر پرو تغییر کرد
// #####################################################################
const newSession = { id: Date.now().toString(), title: 'چت جدید', model: 'gemini-1.5-pro-latest', messages: [] };
// #####################################################################
// END: کد اصلاح شده
// #####################################################################
state.chatSessions.unshift(newSession);
state.setActiveChatId(newSession.id);
ui.renderActiveChat();
ui.renderHistoryList();
}
function getFullChatText(session) {
if (!session || !session.messages) return "";
return session.messages
.map(msg => {
const prefix = msg.role === 'user' ? 'کاربر' : 'مدل';
const textContent = msg.parts?.find(p => p.text)?.text || '[محتوای غیر متنی]';
return ${prefix}:\n${textContent}
;
})
.join('\n\n---\n\n');
}
document.addEventListener('DOMContentLoaded', () => {
ui.initTheme();
state.loadSessions();
textif (state.chatSessions.length === 0 || !state.getActiveChat()) { handleNewChat(); } else { state.setActiveChatId(state.activeChatId || state.chatSessions[0].id); ui.renderActiveChat(); ui.renderHistoryList(); } ui.setupMobileKeyboardFix(); ui.setMinTextareaHeight(ui.dom.messageInput.scrollHeight); ui.dom.messageForm.style.borderRadius = '28px'; ui.dom.newChatButton.addEventListener('click', handleNewChat); ui.dom.menuButton.addEventListener('click', () => ui.toggleSidebar(true)); ui.dom.sidebarOverlay.addEventListener('click', () => ui.toggleSidebar(false)); ui.dom.deleteAllChatsButton.addEventListener('click', () => { ui.showConfirmModal('آیا از حذف تمام چتها مطمئن هستید؟ این عمل غیرقابل بازگشت است.', () => { state.setChatSessions([]); state.setActiveChatId(null); state.saveSessions(); handleNewChat(); ui.toggleSidebar(false); }); }); ui.dom.modelSelectButton.addEventListener('click', () => ui.toggleModal(true)); ui.dom.modelSelectModal.addEventListener('click', (e) => { if (e.target === ui.dom.modelSelectModal) ui.toggleModal(false); }); ui.dom.modelOptionCards.forEach(card => { card.addEventListener('click', () => { const activeChat = state.getActiveChat(); if (!activeChat) return; activeChat.model = card.dataset.model; ui.dom.currentModelName.textContent = card.dataset.name; ui.updateRadioButtons(); state.saveSessions(); ui.toggleModal(false); }); }); ui.dom.settingsButton.addEventListener('click', () => ui.toggleSettingsModal(true)); ui.dom.settingsModal.addEventListener('click', (e) => { if (e.target === ui.dom.settingsModal) ui.toggleSettingsModal(false); }); ui.dom.themeToggle.addEventListener('change', (e) => { const newTheme = e.target.checked ? 'dark' : 'light'; localStorage.setItem('theme', newTheme); ui.applyTheme(newTheme); }); ui.dom.attachFileButton.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = !ui.dom.filePopupMenu.classList.contains('hidden'); ui.toggleFilePopupMenu(!isVisible); }); window.addEventListener('click', (e) => { if (!ui.dom.filePopupMenu.classList.contains('hidden') && !ui.dom.filePopupMenu.contains(e.target) && e.target !== ui.dom.attachFileButton && !ui.dom.attachFileButton.contains(e.target)) { ui.toggleFilePopupMenu(false); } }); ui.dom.selectImageOption.addEventListener('click', () => { ui.dom.imageFileInput.click(); }); ui.dom.selectFileOption.addEventListener('click', () => { ui.dom.generalFileInput.click(); }); ui.dom.imageFileInput.addEventListener('change', handleFileSelection); ui.dom.generalFileInput.addEventListener('change', handleFileSelection); ui.dom.removeImageButton.addEventListener('click', () => { if (state.currentUploadXHR) { state.currentUploadXHR.abort(); console.log("آپلود توسط کاربر لغو شد."); } state.setAttachedFile(null); ui.hideFilePreview(); ui.dom.submitButton.disabled = false; }); ui.dom.htmlPreviewCloseBtn.addEventListener('click', () => ui.toggleHtmlPreviewModal(false)); ui.dom.htmlPreviewOverlay.addEventListener('click', () => ui.toggleHtmlPreviewModal(false)); ui.dom.galleryDownloadBtn.addEventListener('click', function() { const url = ui.getCurrentGalleryImageUrl(); if (url) { api.uploadToAISADAAndOpenAlpha(url, this); } }); ui.dom.messageForm.addEventListener('submit', async (e) => { e.preventDefault(); if (state.isGenerating) { if (state.globalAbortController) state.globalAbortController.abort(); if (state.currentImageEventSource) state.currentImageEventSource.close(); return; } const activeChat = state.getActiveChat(); if (!activeChat) return; const userMessageText = ui.dom.messageInput.value.trim(); if (!userMessageText && !state.attachedFile) return; ui.setGeneratingState(true); const isFirstMessageOfChat = activeChat.messages.length === 0; if (isFirstMessageOfChat) { const welcomeScreen = ui.dom.chatWindow.querySelector('.welcome-screen'); if (welcomeScreen) welcomeScreen.remove(); } const previousLastUserIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); if (previousLastUserIndex !== -1) { const previousUserElement = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${previousLastUserIndex}"]`); if (previousUserElement) { ui.updateMessageActions(previousUserElement, activeChat.messages[previousLastUserIndex], false, false); } } const previousLastModelIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'model'); if (previousLastModelIndex !== -1) { const isItTheLastMessageOverall = previousLastModelIndex === activeChat.messages.length - 1; if (isItTheLastMessageOverall) { const previousModelElement = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${previousLastModelIndex}"]`); if (previousModelElement) { ui.updateMessageActions(previousModelElement, activeChat.messages[previousLastModelIndex], false, false); } } } const userParts = []; if (state.attachedFile) { userParts.push({ fileUrl: state.attachedFile.url, mimeType: state.attachedFile.mimeType, name: state.attachedFile.name, base64Data: state.attachedFile.base64Data }); ui.hideFilePreview(); } if (userMessageText) { userParts.push({ text: userMessageText }); } const newUserMessage = { role: 'user', parts: userParts }; activeChat.messages.push(newUserMessage); ui.addMessageToUI(newUserMessage, activeChat.messages.length - 1, {isLastUser: true, animate: true}); const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages.push(modelPlaceholderMessage); const modelBubbleOuterDiv = ui.addMessageToUI(modelPlaceholderMessage, activeChat.messages.length - 1, {animate: true}); if (isFirstMessageOfChat && userMessageText) { activeChat.title = userMessageText.substring(0, 30) + (userMessageText.length > 30 ? '...' : ''); ui.renderHistoryList(); } ui.dom.messageInput.value = ''; ui.dom.messageInput.dispatchEvent(new Event('input')); try { await api.streamResponse(modelBubbleOuterDiv, activeChat.messages, activeChat.id); } finally { if (state.attachedFile) { state.setAttachedFile(null); } } }); ui.dom.chatWindow.addEventListener('click', async (e) => { const galleryItem = e.target.closest('.js-image-gallery-grid [data-index]'); if (galleryItem) { const grid = galleryItem.closest('.js-image-gallery-grid'); const urls = grid.dataset.urls; const index = galleryItem.dataset.index; if (urls && index !== undefined) { ui.openImageGallery(urls, parseInt(index, 10)); return; } } const clarificationButton = e.target.closest('.clarification-button'); if (clarificationButton) { const payloadString = clarificationButton.dataset.actionPayload; if (!payloadString || state.isGenerating) return; const actionPayload = JSON.parse(payloadString); const activeChat = state.getActiveChat(); if (!activeChat) return; ui.setGeneratingState(true); const clarificationMessageElement = clarificationButton.closest('.message-entry'); const clarificationMessageIndex = parseInt(clarificationMessageElement.dataset.index, 10); if (isNaN(clarificationMessageIndex)) { ui.resetState(); return; } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages[clarificationMessageIndex] = modelPlaceholderMessage; const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, clarificationMessageIndex, {}, clarificationMessageElement); const historyBeforeAction = activeChat.messages.slice(0, clarificationMessageIndex); if (actionPayload.intent === 'edit_image') { let fileUrlForEditing = null; for (let i = historyBeforeAction.length - 1; i >= 0; i--) { const msg = historyBeforeAction[i]; if (msg.parts) { const imagePart = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if(imagePart) { fileUrlForEditing = imagePart.image_url || imagePart.edited_images[0]; break; } const filePart = msg.parts.find(p => p.fileUrl); if(filePart) { fileUrlForEditing = filePart.fileUrl; break; } } } if (fileUrlForEditing) { await api.runExternalImageEditor(actionPayload.prompt, fileUrlForEditing, newModelBubble, clarificationMessageIndex); } else { ui.displayError(newModelBubble, "متاسفانه تصویری برای ویرایش پیدا نشد."); ui.resetState(); } } else if (actionPayload.intent === 'regenerate_with_enhancement') { await api.streamResponse(newModelBubble, historyBeforeAction, activeChat.id, actionPayload); } return; } const button = e.target.closest('.action-button'); if (!button) return; const action = button.dataset.action; const messageEntry = button.closest('.message-entry'); if (!messageEntry) return; const messageIndex = parseInt(messageEntry.dataset.index, 10); const activeChat = state.getActiveChat(); if (!activeChat || isNaN(messageIndex)) return; const message = activeChat.messages[messageIndex]; if (action === 'download-image') { const imageUrl = message.parts.find(p => p.image_url)?.image_url; if (imageUrl) { api.uploadToAISADAAndOpenAlpha(imageUrl, button); } } else if (action === 'copy') { const textToCopy = message.parts?.find(p => p.text)?.text || ''; if (textToCopy) { navigator.clipboard.writeText(textToCopy).then(() => { ui.showCopyFeedback(button); }); } } else if (action === 'like' || action === 'dislike') { ui.handleLikeDislike(button, messageEntry); } else if (action === 'regenerate') { if (state.isGenerating) return; ui.setGeneratingState(true); state.setGpuGuideState(false); const lastModelMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'model'); if (messageIndex === lastModelMessageIndex) { activeChat.messages.length = messageIndex; messageEntry.remove(); const lastUserMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); if (lastUserMessageIndex !== -1) { const lastUserMessageElement = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${lastUserMessageIndex}"]`); if (lastUserMessageElement) { ui.updateMessageActions(lastUserMessageElement, activeChat.messages[lastUserMessageIndex], true, false); } } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages.push(modelPlaceholderMessage); const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, activeChat.messages.length - 1, { animate: true }); await api.streamResponse(newModelBubble, activeChat.messages, activeChat.id); } else { ui.resetState(); } } else if (action === 'edit') { if (state.isGenerating) return; const lastUserMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); if (messageIndex === lastUserMessageIndex) { const textPart = message.parts.find(p => p.text); const filePart = message.parts.find(p => p.fileUrl); if (textPart || filePart) { ui.showEditModal(textPart ? textPart.text : '', async (newText) => { ui.setGeneratingState(true); const allMessagesInDOM = ui.dom.chatWindow.querySelectorAll('.message-entry'); allMessagesInDOM.forEach(msgEl => { const idx = parseInt(msgEl.dataset.index, 10); if (idx >= messageIndex) { msgEl.remove(); } }); activeChat.messages.length = messageIndex; const newParts = []; if (filePart) { newParts.push(filePart); } if (newText.trim()) newParts.push({ text: newText }); if (newParts.length > 0) { const editedUserMessage = { role: 'user', parts: newParts }; activeChat.messages.push(editedUserMessage); ui.addMessageToUI(editedUserMessage, activeChat.messages.length - 1, { isLastUser: true, animate: true }); } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages.push(modelPlaceholderMessage); state.saveSessions(); const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, activeChat.messages.length - 1, { animate: true }); await api.streamResponse(newModelBubble, activeChat.messages, activeChat.id); }); } } } else if (action === 'show-message-menu') { ui.showMessageMenu(e, messageIndex); } }); ui.dom.historyItemMenu.addEventListener('click', (e) => { const button = e.target.closest('.menu-item'); if (!button) return; const action = button.dataset.action; const format = button.dataset.format; const sessionId = ui.dom.historyItemMenu.dataset.sessionId; const session = state.chatSessions.find(s => s.id === sessionId); if (!session) return; if (action === 'rename') { ui.showRenameModal(session.title, (newTitle) => { session.title = newTitle; state.saveSessions(); ui.renderHistoryList(); }); } else if (action === 'delete') { ui.showConfirmModal(`آیا از حذف گفتگوی "${session.title}" مطمئن هستید؟`, () => { state.setChatSessions(state.chatSessions.filter(s => s.id !== sessionId)); state.saveSessions(); if (state.activeChatId === sessionId) { if (state.chatSessions.length > 0) { state.setActiveChatId(state.chatSessions[0].id); ui.renderActiveChat(); } else { handleNewChat(); } } ui.renderHistoryList(); }); } else if (action === 'convert-chat') { const fullText = getFullChatText(session); api.convertTextToFile(fullText, format, button); } ui.dom.historyItemMenu.classList.remove('visible'); }); ui.dom.messageItemMenu.addEventListener('click', (e) => { const menu = ui.dom.messageItemMenu; const closeMenu = () => { menu.classList.remove('visible'); setTimeout(() => { menu.classList.add('hidden'); }, 300); }; if (e.target === ui.dom.messageItemMenuOverlay) { closeMenu(); return; } const button = e.target.closest('.menu-item'); if (!button) return; const action = button.dataset.action; const format = button.dataset.format; const messageIndex = parseInt(menu.dataset.messageIndex, 10); const activeChat = state.getActiveChat(); if (!activeChat || isNaN(messageIndex)) { closeMenu(); return; } const message = activeChat.messages[messageIndex]; if (action === 'delete-message') { ui.showConfirmModal('آیا از حذف این پیام مطمئن هستید؟', () => { state.deleteMessage(activeChat.id, messageIndex); ui.renderActiveChat(); }); } else if (action === 'convert-message') { const textContent = message.parts?.find(p => p.text)?.text || ''; if (textContent) { api.convertTextToFile(textContent, format, button); } else { alert('محتوای متنی برای تبدیل وجود ندارد.'); } } closeMenu(); }); ui.dom.messageInput.addEventListener('input', () => { ui.adjustTextareaHeight(ui.dom.messageInput, ui.dom.messageForm); }); ui.dom.editInput.addEventListener('input', () => { ui.adjustTextareaHeight(ui.dom.editInput); });
});
window.handleSuggestionClick = ui.handleSuggestionClick;
window.uploadToAISADAAndOpenAlpha = api.uploadToAISADAAndOpenAlpha; این یکی // این فایل تمام وضعیتهای برنامه را مدیریت میکند
// START: تغییرات برای محدود کردن سابقه چت
export const MAX_CHAT_SESSIONS = 150; // حد مجاز تعداد تاریخچه چت
// END: تغییرات برای محدود کردن سابقه چت
export let chatSessions = [];
export let activeChatId = null;
export let attachedFile = null;
export let isGenerating = false;
export let globalAbortController = null;
export let currentImageEventSource = null;
export let currentUploadXHR = null;
export let isGpuGuideActive = false;
export function setChatSessions(newSessions) {
chatSessions = newSessions;
}
export function setActiveChatId(id) {
activeChatId = id;
}
export function setAttachedFile(file) {
attachedFile = file;
}
export function setGenerating(status) {
isGenerating = status;
}
export function setGlobalAbortController(controller) {
globalAbortController = controller;
}
export function setCurrentImageEventSource(source) {
currentImageEventSource = source;
}
export function setCurrentUploadXHR(xhr) {
currentUploadXHR = xhr;
}
export function setGpuGuideState(isActive) {
isGpuGuideActive = isActive;
}
export function getActiveChat() {
return chatSessions.find(s => s.id === activeChatId);
}
// START: تابع جدید برای اعمال محدودیت تعداد سابقه
/**
تعداد تاریخچه به ${MAX_CHAT_SESSIONS} محدود شد. قدیمیترینها حذف شدند.
);// *** تابع اصلاح شده ***
export function saveSessions() {
// START: تغییرات برای محدود کردن سابقه چت
// قبل از ذخیره، محدودیت را اعمال میکنیم
enforceSessionLimit();
// END: تغییرات برای محدود کردن سابقه چت
texttry { // ایجاد یک کپی عمیق برای جلوگیری از تغییر داده اصلی const sessionsToSave = JSON.parse(JSON.stringify(chatSessions)); // حذف base64Data از تمام پیامها قبل از ذخیرهسازی sessionsToSave.forEach(session => { session.messages.forEach(message => { if (message.parts) { message.parts.forEach(part => { delete part.base64Data; }); } }); }); localStorage.setItem('alphaChatSessions', JSON.stringify(sessionsToSave)); } catch (e) { console.error("Failed to save sessions to localStorage:", e); }
}
// *** تابع اصلاح شده ***
export function loadSessions() {
try {
const saved = localStorage.getItem('alphaChatSessions');
chatSessions = saved ? JSON.parse(saved) : [];
text// START: تغییرات برای محدود کردن سابقه چت // پس از بارگذاری، محدودیت را اعمال میکنیم تا موارد اضافی موجود پاک شوند enforceSessionLimit(); // END: تغییرات برای محدود کردن سابقه چت } catch (e) { console.error("Failed to load sessions from localStorage:", e); chatSessions = []; }
}
export function findLastIndex(array, predicate) {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) { return i; }
}
return -1;
}
export function deleteMessage(chatId, messageIndex) {
const chat = chatSessions.find(s => s.id === chatId);
if (chat && chat.messages[messageIndex]) {
chat.messages.splice(messageIndex, 1);
saveSessions();
}
} این یکی import * as state from './state.js';
export const dom = {
appContainer: document.getElementById('app-container'),
chatWindow: document.getElementById('chat-window'),
mainHeader: document.getElementById('main-header'),
mainFooter: document.getElementById('main-footer'),
messageForm: document.getElementById('message-form'),
messageInput: document.getElementById('message-input'),
submitButton: document.getElementById('submit-button'),
sendIcon: document.getElementById('send-icon'),
stopIcon: document.getElementById('stop-icon'),
menuButton: document.getElementById('menu-button'),
newChatButton: document.getElementById('new-chat-button'),
historySidebar: document.getElementById('history-sidebar'),
sidebarOverlay: document.getElementById('sidebar-overlay'),
historyList: document.getElementById('history-list'),
deleteAllChatsButton: document.getElementById('delete-all-chats'),
modelSelectButton: document.getElementById('model-select-button'),
currentModelName: document.getElementById('current-model-name'),
modelSelectModal: document.getElementById('model-select-modal'),
modalContent: document.getElementById('modal-content'),
modelOptionCards: document.querySelectorAll('.model-option-card'),
attachFileButton: document.getElementById('attach-file-button'),
imageFileInput: document.getElementById('image-file-input'),
generalFileInput: document.getElementById('general-file-input'),
imagePreviewContainer: document.getElementById('image-preview-container'),
imagePreview: document.getElementById('image-preview'),
removeImageButton: document.getElementById('remove-image-button'),
fileInfoText: document.getElementById('file-info-text'),
historyItemMenu: document.getElementById('history-item-menu'),
messageItemMenu: document.getElementById('message-item-menu'),
messageItemMenuOverlay: document.getElementById('message-item-menu-overlay'),
messageItemMenuContent: document.getElementById('message-item-menu-content'),
confirmModal: document.getElementById('confirm-modal'),
confirmModalOverlay: document.getElementById('confirm-modal-overlay'),
confirmModalContent: document.getElementById('confirm-modal-content'),
confirmModalMessage: document.getElementById('confirm-modal-message'),
confirmModalConfirmBtn: document.getElementById('confirm-modal-confirm-btn'),
confirmModalCancelBtn: document.getElementById('confirm-modal-cancel-btn'),
renameModal: document.getElementById('rename-modal'),
renameModalOverlay: document.getElementById('rename-modal-overlay'),
renameModalContent: document.getElementById('rename-modal-content'),
renameInput: document.getElementById('rename-input'),
renameModalConfirmBtn: document.getElementById('rename-modal-confirm-btn'),
renameModalCancelBtn: document.getElementById('rename-modal-cancel-btn'),
editModal: document.getElementById('edit-modal'),
editModalOverlay: document.getElementById('edit-modal-overlay'),
editModalContent: document.getElementById('edit-modal-content'),
editInput: document.getElementById('edit-input'),
editModalConfirmBtn: document.getElementById('edit-modal-confirm-btn'),
editModalCancelBtn: document.getElementById('edit-modal-cancel-btn'),
htmlPreviewModal: document.getElementById('html-preview-modal'),
htmlPreviewOverlay: document.getElementById('html-preview-overlay'),
htmlPreviewContent: document.getElementById('html-preview-content'),
htmlPreviewIframe: document.getElementById('html-preview-iframe'),
htmlPreviewCloseBtn: document.getElementById('html-preview-close-btn'),
filePopupMenu: document.getElementById('file-popup-menu'),
selectImageOption: document.getElementById('select-image-option'),
selectFileOption: document.getElementById('select-file-option'),
imageGalleryModal: document.getElementById('image-gallery-modal'),
imageGalleryContent: document.getElementById('image-gallery-content'),
galleryCloseBtn: document.getElementById('gallery-close-btn'),
galleryMainImage: document.getElementById('gallery-main-image'),
galleryPrevBtn: document.getElementById('gallery-prev-btn'),
galleryNextBtn: document.getElementById('gallery-next-btn'),
galleryThumbnails: document.getElementById('gallery-thumbnails'),
galleryDownloadBtn: document.getElementById('gallery-download-btn'),
settingsButton: document.getElementById('settings-button'),
settingsModal: document.getElementById('settings-modal'),
settingsModalContent: document.getElementById('settings-modal-content'),
themeToggle: document.getElementById('theme-toggle'),
};
const MAX_TEXTAREA_HEIGHT = 120;
const MAX_RADIUS = 28;
const MIN_RADIUS = 20;
export let minTextareaHeight = 0;
let currentGalleryImages = [];
let currentGalleryIndex = 0;
function escapeHTML(str) {
const p = document.createElement("p");
p.textContent = str;
return p.innerHTML;
}
export function setMinTextareaHeight(height) { minTextareaHeight = height; }
export function getCurrentGalleryImageUrl() { return currentGalleryImages[currentGalleryIndex]; }
function getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎬';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType.startsWith('application/pdf')) return '📄';
if (mimeType.startsWith('text/')) return '📝';
return '📁';
}
export function hideFilePreview() {
dom.imagePreviewContainer.classList.add('hidden');
dom.imagePreview.src = '';
dom.fileInfoText.innerHTML = '';
dom.imageFileInput.value = '';
dom.generalFileInput.value = '';
}
export function showFileUploading(fileName) {
dom.imagePreview.src = data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='animate-spin' fill='none' viewBox='0 0 24 24' stroke-width='2' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707'/%3E%3C/svg%3E
;
dom.fileInfoText.innerHTML = <div class='flex flex-col'><span class='font-semibold'>${fileName}</span><div class='text-xs text-slate-500'>در حال آپلود... <span class='upload-progress'>0%</span></div></div>
;
dom.imagePreviewContainer.classList.remove('hidden');
}
export function updateUploadProgress(percent) {
const progressSpan = dom.fileInfoText.querySelector('.upload-progress');
if(progressSpan) progressSpan.textContent = ${percent}%
;
}
export function showFileReady(fileName, mimeType, url) {
const icon = getFileIcon(mimeType);
if(mimeType.startsWith('image/')) {
dom.imagePreview.src = url;
} else {
dom.imagePreview.src = data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z'%3E%3C/path%3E%3Cpolyline points='13 2 13 9 20 9'%3E%3C/polyline%3E%3C/svg%3E
;
}
dom.fileInfoText.innerHTML = <div class='flex flex-col'><span class='font-semibold'>${icon} ${fileName}</span><span class='text-xs text-green-600'>فایل برای ارسال آماده است.</span></div>
;
dom.imagePreviewContainer.classList.remove('hidden');
}
export function showFileError(errorMessage) {
dom.imagePreview.src = data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' /%3E%3C/svg%3E
;
dom.fileInfoText.innerHTML = <div class='flex flex-col'><span class='font-semibold text-red-600'>خطا در آپلود</span><span class='text-xs text-red-500'>${errorMessage}</span></div>
;
dom.imagePreviewContainer.classList.remove('hidden');
setTimeout(hideFilePreview, 5000);
}
export function handleSuggestionClick(text) {
dom.messageInput.value = text;
dom.messageInput.dispatchEvent(new Event('input', { bubbles: true }));
dom.messageInput.focus();
}
export function runWelcomeAnimation() {
const chatbotNameContainer = document.querySelector('.chatbot-name');
const mainTitle = document.querySelector('.main-title');
const buttons = document.querySelectorAll('.action-button-welcome');
if (!chatbotNameContainer || !mainTitle || buttons.length === 0) return;
const textToType = "چت بات آلفا";
let charIndex = 0;
const typingSpeed = 90;
function typeChatbotName() {
if (charIndex < textToType.length) {
chatbotNameContainer.textContent += textToType.charAt(charIndex);
charIndex++;
setTimeout(typeChatbotName, typingSpeed);
} else {
chatbotNameContainer.style.opacity = '1';
setTimeout(() => { mainTitle.style.opacity = '1'; }, 500);
setTimeout(() => {
buttons.forEach((button, index) => {
setTimeout(() => {
button.style.opacity = '1';
button.style.transform = 'translateY(0)';
}, index * 120);
});
}, 1200);
}
}
chatbotNameContainer.textContent = '';
typeChatbotName();
}
export function setupCodeBlockActions(container) {
const codeBlocks = container.querySelectorAll('pre code');
codeBlocks.forEach(el => {
if (el.dataset.highlighted === 'yes') return;
texthljs.highlightElement(el); el.dataset.highlighted = 'yes'; const pre = el.parentElement; let buttonContainer = pre.querySelector('.code-button-container'); if (buttonContainer) buttonContainer.remove(); buttonContainer = document.createElement('div'); buttonContainer.className = 'code-button-container'; const copyButton = document.createElement('button'); copyButton.className = 'code-button'; copyButton.innerHTML = `<span class="copy-text">کپی</span><svg class="copy-tick-icon hidden w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path></svg>`; copyButton.onclick = () => { navigator.clipboard.writeText(el.innerText).then(() => { const copyTextSpan = copyButton.querySelector('.copy-text'); const copyTickIcon = copyButton.querySelector('.copy-tick-icon'); if (copyTextSpan && copyTickIcon) { copyTextSpan.classList.add('hidden'); copyTickIcon.classList.remove('hidden'); setTimeout(() => { copyTickIcon.classList.add('hidden'); copyTextSpan.classList.remove('hidden'); }, 2000); } }); }; buttonContainer.appendChild(copyButton); const languageClass = Array.from(el.classList).find(cls => cls.startsWith('language-')); if (languageClass === 'language-html') { const runButton = document.createElement('button'); runButton.className = 'code-button'; runButton.innerHTML = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"></path></svg><span>اجرا</span>`; runButton.onclick = () => { toggleHtmlPreviewModal(true, el.innerText); }; buttonContainer.appendChild(runButton); } pre.appendChild(buttonContainer); });
}
const createMenuItem = (options) => {
const { action, format = '', text, icon, isDanger = false, type = 'button' } = options;
const element = document.createElement(type);
element.className = menu-item ${isDanger ? 'danger' : ''}
;
element.dataset.action = action;
if (format) element.dataset.format = format;
element.innerHTML = ${icon}<span>${text}</span><div class="hidden w-4 h-4 border-2 border-slate-300 border-t-blue-500 rounded-full animate-spin ml-auto"></div>
;
return element;
};
const getConversionMenuItems = (action) => ${createMenuItem({ action, format: 'pdf', text: 'تبدیل به PDF', icon:
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></svg>}).outerHTML} ${createMenuItem({ action, format: 'docx', text: 'تبدیل به Word', icon:
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H15M2.25 3h1.5M2.25 6h1.5M2.25 9h1.5M2.25 12h1.5M2.25 15h1.5M2.25 18h1.5M4.5 21h15a2.25 2.25 0 002.25-2.25V5.25A2.25 2.25 0 0019.5 3h-15A2.25 2.25 0 002.25 5.25v13.5A2.25 2.25 0 004.5 21z" /></svg>}).outerHTML} ${createMenuItem({ action, format: 'txt', text: 'تبدیل به Text', icon:
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>}).outerHTML} ${createMenuItem({ action, format: 'html', text: 'تبدیل به HTML', icon:
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg>}).outerHTML}
;
export function renderHistoryList() {
dom.historyList.innerHTML = '';
const chatsToDisplay = state.chatSessions.filter(session => session.messages.length > 0 || session.id === state.activeChatId);
if (chatsToDisplay.length > 0) {
chatsToDisplay.forEach((session) => {
const itemContainer = document.createElement('div');
itemContainer.className = 'history-item flex items-center justify-between rounded-lg';
const itemLink = document.createElement('a');
itemLink.href = '#';
itemLink.className = flex-grow p-3 truncate transition-colors rounded-lg ${session.id === state.activeChatId ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold' : 'hover:bg-slate-200/60 dark:hover:bg-slate-700/60 text-slate-700 dark:text-slate-300'}
;
itemLink.textContent = session.title;
itemLink.onclick = (e) => { e.preventDefault(); state.setActiveChatId(session.id); renderActiveChat(); renderHistoryList(); toggleSidebar(false); };
const menuButton = document.createElement('button');
menuButton.className = 'history-item-button p-2 ml-1 text-slate-500 dark:text-slate-400 hover:bg-slate-200/80 dark:hover:bg-slate-700/80 rounded-full flex-shrink-0';
menuButton.innerHTML = '<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" /></svg>';
menuButton.onclick = (e) => showHistoryMenu(e, session.id);
itemContainer.appendChild(itemLink);
itemContainer.appendChild(menuButton);
dom.historyList.appendChild(itemContainer);
});
}
}
export function renderActiveChat() {
dom.chatWindow.innerHTML = '';
const activeChat = state.getActiveChat();
textif (activeChat && activeChat.messages.length === 0) { dom.chatWindow.innerHTML = `<div class="welcome-screen"><div class="welcome-container"><div class="chatbot-name"></div><h1 class="main-title">چطور میتوانم به شما کمک کنم؟</h1><div class="buttons-grid"><a href="#" class="action-button-welcome" onclick="handleSuggestionClick('یک تصویر هنری از یک فضانورد در حال قدم زدن روی مریخ تولید کن')"><div class="button-icon green-icon">🖼️</div><div class="button-text">تولید عکس</div></a><a href="#" class="action-button-welcome" onclick="handleSuggestionClick('این فایل را خلاصه کن و نکات کلیدی آن را استخراج کن')"><div class="button-icon orange-icon">📄</div><div class="button-text">دسترسی به فایل</div></a><a href="#" class="action-button-welcome" onclick="handleSuggestionClick('آخرین اخبار در مورد هوش مصنوعی را برایم پیدا کن')"><div class="button-icon blue-icon">🔍</div><div class="button-text">جستوجو در اینترنت</div></a><a href="#" class="action-button-welcome" onclick="handleSuggestionClick('یک ایده برای برنامهریزی سفر به ایتالیا به من بده')"><div class="button-icon purple-icon">✈️</div><div class="button-text">برنامهریزی سفر</div></a></div></div></div>`; runWelcomeAnimation(); } else if (activeChat && activeChat.messages.length > 0) { const lastMessageIndex = activeChat.messages.length - 1; const lastUserMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); activeChat.messages.forEach((msg, index) => { if (msg.isTemporary) return; const isLastUser = (index === lastUserMessageIndex); const isLastModel = (index === lastMessageIndex && msg.role === 'model'); addMessageToUI(msg, index, { isLastUser: isLastUser, isLastModel: isLastModel, animate: false }); }); } if (activeChat) { const modelCard = document.querySelector(`.model-option-card[data-model="${activeChat.model}"]`); if (modelCard) { dom.currentModelName.textContent = modelCard.dataset.name; } else { const defaultModelCard = dom.modelOptionCards[0]; activeChat.model = defaultModelCard.dataset.model; dom.currentModelName.textContent = defaultModelCard.dataset.name; } updateRadioButtons(); } requestAnimationFrame(() => { dom.chatWindow.scrollTop = dom.chatWindow.scrollHeight; });
}
export function createMessageActionsHtml(options) {
const { role, isLastUser, isLastModel, messageObject } = options;
let buttonsHtml = '';
const textContent = messageObject?.parts.find(p => p.text)?.text;
const copyButtonHtml = <button data-action="copy" title="کپی" class="action-button relative"><svg class="w-4 h-4 copy-icon" fill="currentColor" viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2z"/></svg><svg class="w-4 h-4 check-icon hidden text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg><span class="copy-feedback">کپی شد!</span></button>
;
const menuButtonHtml = <button data-action="show-message-menu" title="گزینههای بیشتر" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></button>
;
textif (role === 'user') { if (textContent) { buttonsHtml += copyButtonHtml; } if (isLastUser && textContent) { buttonsHtml += `<button data-action="edit" title="ویرایش" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg></button>`; } buttonsHtml += menuButtonHtml; } if (role === 'model') { const hasTextContent = messageObject?.parts.some(p => p.text); const hasDownloadableContent = messageObject?.parts.some(p => p.image_url || p.edited_images); const isClarification = !!messageObject?.clarification; const isGpuGuide = !!messageObject?.isGpuGuide; if (hasDownloadableContent) { buttonsHtml += `<button data-action="download-image" title="دانلود تصویر" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg></button>`; } if (hasTextContent) { buttonsHtml += copyButtonHtml; } if (isLastModel && !isClarification && !isGpuGuide) { buttonsHtml += `<button data-action="regenerate" title="تولید مجدد" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"/></svg></button>`; } if (hasTextContent && !isClarification && !isGpuGuide) { buttonsHtml += `<button data-action="like" title="پسندیدم" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg></button><button data-action="dislike" title="نپسندیدم" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14-.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41-.17-.79-.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg></button>`; } buttonsHtml += menuButtonHtml; } return buttonsHtml ? `<div class="message-actions"><div class="flex items-center gap-1.5">${buttonsHtml}</div></div>` : '';
}
function createFileContentHtml(filePart) {
const { fileUrl, mimeType, name } = filePart;
let fileHtml = '';
textif (mimeType.startsWith('image/')) { fileHtml = `<img src="${fileUrl}" alt="${name}" class="rounded-lg mb-2 max-w-xs sm:max-w-sm md:max-w-md">`; } else if (mimeType.startsWith('video/')) { fileHtml = `<video controls src="${fileUrl}" class="rounded-lg mb-2 max-w-xs sm:max-w-sm md:max-w-md"></video>`; } else if (mimeType.startsWith('audio/')) { fileHtml = `<audio controls src="${fileUrl}" class="w-full max-w-xs"></audio>`; } else { fileHtml = `<div class="flex items-center gap-3 p-3 bg-slate-100 dark:bg-slate-700/50 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-200 text-sm"> <svg class="w-8 h-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /></svg> <div class="flex flex-col overflow-hidden"> <span class="font-semibold truncate">${name}</span> <a href="${fileUrl}" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:underline">دانلود فایل</a> </div> </div>`; } return fileHtml;
}
export function addMessageToUI(message, index, options = {}, existingElement = null) {
const { role, parts } = message;
const { isLastUser = false, isLastModel = false, animate = true } = options;
const isUser = role === 'user';
const messageId = msg-${Date.now()}-${index}
;
textlet finalElement = existingElement; if (!finalElement) { finalElement = document.createElement('div'); finalElement.className = `message-entry mb-6 flex items-end gap-3 ${isUser ? 'justify-end' : 'justify-start'}`; finalElement.dataset.index = index; if (animate) finalElement.classList.add('message-entry'); const messageBubbleClasses = isUser ? 'bg-gradient-to-br from-blue-500 to-purple-600 text-white rounded-br-none' : 'bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-200 rounded-bl-none'; const userIcon = `<div class="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 bg-blue-600 text-white"><svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"></path></svg></div>`; const modelIcon = `<div class="w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 bg-slate-700 dark:bg-slate-600 text-white"><svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z"></path></svg></div>`; finalElement.innerHTML = ` ${!isUser ? modelIcon : ''} <div class="relative group w-11/12 md:max-w-xl"> <div id="${messageId}" class="p-4 rounded-2xl shadow-sm ${messageBubbleClasses}"> <div class="message-content-area"></div> </div> </div> ${isUser ? userIcon : ''} `; dom.chatWindow.appendChild(finalElement); } const contentArea = finalElement.querySelector('.message-content-area'); contentArea.innerHTML = ''; if (message.clarification) { let contentHtml = `<p class="mb-4 text-slate-700 dark:text-slate-300">${message.question}</p>`; contentHtml += '<div class="flex flex-col gap-3">'; const optionsData = message.clarification; const editIcon = `<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 011.941-5.585L14.25 6l5.5 5.5-3.094 3.093a4.5 4.5 0 01-5.585 1.941z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.75l5.5 5.5"></path></svg>`; const regenerateIcon = `<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.664 0l3.181-3.183m-4.991-2.695v4.992m0 0h-4.992m4.992 0l-3.181-3.183a8.25 8.25 0 00-11.664 0l-3.181 3.183"></path></svg>`; if (optionsData.edit) { const editPayload = JSON.stringify(optionsData.edit); contentHtml += `<button class="clarification-button glass-button" data-action-payload='${editPayload}'>${editIcon}<span>${optionsData.edit.label}</span></button>`; } if (optionsData.regenerate) { const regeneratePayload = JSON.stringify(optionsData.regenerate); contentHtml += `<button class="clarification-button glass-button" data-action-payload='${regeneratePayload}'>${regenerateIcon}<span>${optionsData.regenerate.label}</span></button>`; } contentHtml += '</div>'; contentArea.innerHTML = contentHtml; } else if (parts && parts.length > 0) { let allContent = ''; parts.forEach(part => { if (isUser && part.fileUrl) allContent += createFileContentHtml(part); if (part.image_url) allContent += `<img src="${part.image_url}" alt="generated image" class="rounded-lg max-w-full">`; if (part.edited_images && Array.isArray(part.edited_images)) { const imageUrlsString = JSON.stringify(part.edited_images); let galleryHtml = `<div class="grid grid-cols-2 gap-2 js-image-gallery-grid" data-urls='${imageUrlsString}'>`; part.edited_images.forEach((url, i) => { galleryHtml += `<div class="relative group cursor-pointer" data-index="${i}"><img src="${url}" alt="edited image" class="pointer-events-none rounded-lg w-full h-full object-cover"></div>`; }); galleryHtml += '</div>'; allContent += galleryHtml; } if (part.text) { if (isUser) { const escapedText = escapeHTML(part.text); allContent += `<div class="whitespace-pre-wrap">${escapedText}</div>`; } else { const parsedText = marked.parse(part.text, { breaks: true, gfm: true }); allContent += `<div class="prose prose-sm md:prose-base max-w-none dark:prose-invert">${parsedText}</div>`; } } }); contentArea.innerHTML = allContent; } else if (!isUser) { contentArea.innerHTML = `<div class="typing-indicator"><span></span><span></span><span></span></div>`; } if (!isUser) { setupCodeBlockActions(contentArea); } updateMessageActions(finalElement, message, isLastUser, isLastModel); finalElement.scrollIntoView({ behavior: 'smooth', block: 'end' }); return finalElement;
}
export function showStatusUpdate(message, modelBubbleOuterDivElement) {
const contentArea = modelBubbleOuterDivElement.querySelector('.message-content-area');
let statusDiv = modelBubbleOuterDivElement.querySelector('.status-update-area');
textconst typingIndicator = contentArea.querySelector('.typing-indicator'); if (typingIndicator) typingIndicator.remove(); if (!statusDiv) { statusDiv = document.createElement('div'); statusDiv.className = 'status-update-area p-2 text-sm text-slate-600 dark:text-slate-400 flex items-center gap-2 mt-3 border-t border-slate-200/50 dark:border-slate-600/50'; contentArea.prepend(statusDiv); } statusDiv.innerHTML = `<div class="typing-indicator" style="height:16px; gap: 3px;"><span style="width:6px; height:6px;"></span><span style="width:6px; height:6px;"></span><span style="width:6px; height:6px;"></span></div><p>${message}</p>`; requestAnimationFrame(() => { dom.chatWindow.scrollTop = dom.chatWindow.scrollHeight; });
}
export function addPartialResult(data, modelBubbleOuterDivElement) {
const contentArea = modelBubbleOuterDivElement.querySelector('.message-content-area');
const partialResultDiv = document.createElement('div');
partialResultDiv.className = 'partial-result-container border-t border-slate-200/50 mt-2 pt-2';
textif (data.contentType === 'image' && data.url) { partialResultDiv.innerHTML = `<img src="${data.url}" alt="partial result" class="rounded-lg max-w-xs mx-auto my-2">`; } else if (data.contentType === 'code' && data.content) { partialResultDiv.innerHTML = `<div class="prose prose-sm md:prose-base max-w-none dark:prose-invert">${marked.parse(data.content, { breaks: true, gfm: true })}</div>`; setupCodeBlockActions(partialResultDiv); } const statusDiv = contentArea.querySelector('.status-update-area'); if (statusDiv) { contentArea.insertBefore(partialResultDiv, statusDiv); } else { contentArea.appendChild(partialResultDiv); }
}
export function streamFinalText(text, modelBubbleOuterDivElement) {
const contentArea = modelBubbleOuterDivElement.querySelector('.message-content-area');
let finalTextArea = contentArea.querySelector('.final-text-area');
textif (!finalTextArea) { contentArea.innerHTML = ''; finalTextArea = document.createElement('div'); finalTextArea.className = 'final-text-area prose prose-sm md:prose-base max-w-none dark:prose-invert'; contentArea.appendChild(finalTextArea); } finalTextArea.innerHTML = marked.parse(text, { breaks: true, gfm: true }); setupCodeBlockActions(finalTextArea); requestAnimationFrame(() => { dom.chatWindow.scrollTop = dom.chatWindow.scrollHeight; });
}
export function updateMessageActions(messageOuterDivElement, messageObject, isLastUser, isLastModel) {
const messageWrapper = messageOuterDivElement.querySelector('.group');
if (!messageWrapper) return;
let oldActionsContainer = messageWrapper.querySelector('.message-actions');
if (oldActionsContainer) { oldActionsContainer.remove(); }
const newActionsHtml = createMessageActionsHtml({ role: messageObject.role, isLastUser: isLastUser, isLastModel: isLastModel, messageObject: messageObject });
if (newActionsHtml) { messageWrapper.insertAdjacentHTML('beforeend', newActionsHtml); }
}
export function adjustTextareaHeight(el, formEl = null) {
el.style.height = 'auto';
el.style.height = ${el.scrollHeight}px
;
textif (formEl) { const currentHeight = el.scrollHeight; const heightRange = MAX_TEXTAREA_HEIGHT - minTextareaHeight; if (heightRange > 0) { const progress = Math.max(0, Math.min(1, (currentHeight - minTextareaHeight) / heightRange)); const radiusRange = MAX_RADIUS - MIN_RADIUS; const newRadius = MAX_RADIUS - (radiusRange * progress); formEl.style.borderRadius = `${newRadius}px`; } }
}
export function showCopyFeedback(button) {
const copyIcon = button.querySelector('.copy-icon');
const checkIcon = button.querySelector('.check-icon');
const feedback = button.querySelector('.copy-feedback');
if (copyIcon && checkIcon && feedback) {
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
feedback.classList.add('visible');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
feedback.classList.remove('visible');
}, 2000);
}
}
export function handleLikeDislike(button, messageEntry) {
const isActive = button.classList.toggle('active');
if (isActive) {
button.classList.add('like-animation');
button.addEventListener('animationend', () => button.classList.remove('like-animation'), { once: true });
const action = button.dataset.action;
const siblingAction = action === 'like' ? 'dislike' : 'like';
const siblingButton = messageEntry.querySelector([data-action="${siblingAction}"]
);
if (siblingButton) siblingButton.classList.remove('active');
}
}
export function resetState() {
state.setGenerating(false);
dom.submitButton.classList.remove('is-loading');
dom.submitButton.title = 'ارسال';
dom.submitButton.disabled = false;
dom.messageInput.disabled = false;
dom.attachFileButton.disabled = false;
state.setGlobalAbortController(null);
if (state.currentImageEventSource) {
state.currentImageEventSource.close();
state.setCurrentImageEventSource(null);
}
}
export function setGeneratingState(generating) {
state.setGenerating(generating);
if (generating) {
state.setGlobalAbortController(new AbortController());
dom.submitButton.classList.add('is-loading');
dom.submitButton.title = 'توقف تولید';
dom.submitButton.disabled = false;
dom.messageInput.disabled = true;
dom.attachFileButton.disabled = true;
} else {
resetState();
}
}
export function displayError(modelBubbleOuterDivElement, errorMessage, errorType = 'generic') {
const messageBubbleContentDiv = modelBubbleOuterDivElement.querySelector('.message-content-area');
const messageWrapper = modelBubbleOuterDivElement.querySelector('.group');
textlet oldActionsContainer = messageWrapper.querySelector('.message-actions'); if (oldActionsContainer) { oldActionsContainer.remove(); } if (errorType === 'gpu_quota_exceeded') { state.setGpuGuideState(true); const activeChat = state.getActiveChat(); if(activeChat) { const lastMsg = activeChat.messages[activeChat.messages.length - 1]; if(lastMsg) lastMsg.isGpuGuide = true; state.saveSessions(); } let content = ` <div class="gpu-error-guide"> <p class="font-semibold mb-3">متاسفانه با محدودیت موقت سرور مواجه شدیم. برای رفع آن، لطفا نوع اینترنت خود را انتخاب کنید:</p> <div class="flex gap-2 mb-3" id="internet-type-selector"> <button class="clarification-button flex-1" data-type="sim">📱 سیمکارت</button> <button class="clarification-button flex-1" data-type="wifi">📶 وایفای</button> </div> <div id="instructions-container" class="text-sm mt-3 p-3 bg-slate-100 dark:bg-slate-700/50 rounded-md" style="display: none;"></div> </div>`; messageBubbleContentDiv.innerHTML = content; const container = messageBubbleContentDiv.querySelector('.gpu-error-guide'); const instructionsContainer = container.querySelector('#instructions-container'); const showInstructions = (type) => { instructionsContainer.style.display = 'block'; if (type === 'sim') { instructionsContainer.innerHTML = `<p><strong>راه حل (سیمکارت):</strong></p><ol class="list-decimal list-inside pr-4 mt-2 space-y-1"><li>اگر فیلترشکن شما روشن است، آن را <strong>خاموش</strong> کنید.</li><li>گوشی را برای <strong>۱۰ ثانیه</strong> در حالت هواپیما قرار دهید.</li><li>حالت هواپیما را <strong>خاموش</strong> کنید.</li></ol>`; } else { instructionsContainer.innerHTML = `<p><strong>راه حل (وایفای):</strong></p><ol class="list-decimal list-inside pr-4 mt-2 space-y-1"><li>مودم وایفای خود را برای <strong>۳۰ ثانیه</strong> از برق بکشید.</li><li>دوباره مودم را به برق وصل کرده و منتظر بمانید تا روشن شود.</li></ol>`; } }; container.querySelector('[data-type="sim"]').onclick = () => showInstructions('sim'); container.querySelector('[data-type="wifi"]').onclick = () => showInstructions('wifi'); } else { const errorIcon = `<svg class="w-6 h-6 mr-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"></path></svg>`; messageBubbleContentDiv.innerHTML = `${errorIcon}<p class="whitespace-pre-wrap">${errorMessage}</p>`; } messageBubbleContentDiv.parentElement.className = 'p-4 rounded-2xl shadow-sm relative flex items-center bg-red-100 dark:bg-red-800/20 border border-red-200 dark:border-red-600/30 text-red-800 dark:text-red-300 rounded-bl-none'; const regenerateButtonHtml = `<button data-action="regenerate" title="تلاش مجدد" class="action-button"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"></path></svg></button>`; const newActionsHtml = `<div class="message-actions"><div class="flex items-center gap-1.5">${regenerateButtonHtml}</div></div>`; if (messageWrapper) { messageWrapper.insertAdjacentHTML('beforeend', newActionsHtml); } resetState();
}
function showImageInGallery(index) {
if (index < 0 || index >= currentGalleryImages.length) return;
currentGalleryIndex = index;
const newImageUrl = currentGalleryImages[index];
textdom.galleryMainImage.style.opacity = '0'; setTimeout(() => { dom.galleryMainImage.src = newImageUrl; dom.galleryMainImage.style.opacity = '1'; }, 150); const thumbnails = dom.galleryThumbnails.querySelectorAll('.gallery-thumb'); thumbnails.forEach((thumb, i) => { thumb.classList.toggle('active', i === index); if (i === index) { thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } });
}
const handleGalleryKeyDown = (e) => {
if (e.key === 'ArrowRight') showImageInGallery((currentGalleryIndex + 1) % currentGalleryImages.length);
else if (e.key === 'ArrowLeft') showImageInGallery((currentGalleryIndex - 1 + currentGalleryImages.length) % currentGalleryImages.length);
else if (e.key === 'Escape') closeImageGallery();
};
export function openImageGallery(imageUrlsString, startIndex) {
try {
currentGalleryImages = JSON.parse(imageUrlsString);
} catch (e) {
console.error("Failed to parse image URLs for gallery:", e);
return;
}
textif (!currentGalleryImages || currentGalleryImages.length === 0) return; dom.galleryThumbnails.innerHTML = ''; currentGalleryImages.forEach((url, index) => { const thumb = document.createElement('img'); thumb.src = url; thumb.className = 'gallery-thumb'; thumb.onclick = () => showImageInGallery(index); dom.galleryThumbnails.appendChild(thumb); }); showImageInGallery(startIndex); dom.imageGalleryModal.classList.remove('hidden'); requestAnimationFrame(() => { dom.imageGalleryModal.classList.add('visible'); }); dom.galleryCloseBtn.onclick = closeImageGallery; dom.imageGalleryModal.onclick = (e) => { if (e.target === dom.imageGalleryModal) closeImageGallery(); }; dom.galleryNextBtn.onclick = () => showImageInGallery((currentGalleryIndex + 1) % currentGalleryImages.length); dom.galleryPrevBtn.onclick = () => showImageInGallery((currentGalleryIndex - 1 + currentGalleryImages.length) % currentGalleryImages.length); window.addEventListener('keydown', handleGalleryKeyDown);
}
function closeImageGallery() {
dom.imageGalleryModal.classList.remove('visible');
setTimeout(() => {
dom.imageGalleryModal.classList.add('hidden');
dom.galleryMainImage.src = '';
}, 300);
window.removeEventListener('keydown', handleGalleryKeyDown);
}
export function toggleSidebar(show) {
if (show) {
dom.sidebarOverlay.classList.remove('hidden');
requestAnimationFrame(() => {
dom.sidebarOverlay.style.opacity = '1';
dom.historySidebar.style.transform = 'translateX(0)';
});
} else {
dom.sidebarOverlay.style.opacity = '0';
dom.historySidebar.style.transform = 'translateX(100%)';
setTimeout(() => dom.sidebarOverlay.classList.add('hidden'), 300);
}
}
export function toggleModal(show) {
if (show) {
dom.modelSelectModal.classList.remove('hidden');
requestAnimationFrame(() => {
dom.modelSelectModal.style.opacity = '1';
dom.modalContent.style.opacity = '1';
dom.modalContent.style.transform = 'scale(1)';
});
} else {
dom.modelSelectModal.style.opacity = '0';
dom.modalContent.style.opacity = '0';
dom.modalContent.style.transform = 'scale(0.95)';
setTimeout(() => dom.modelSelectModal.classList.add('hidden'), 200);
}
}
export function toggleEditModal(show) {
if (show) {
dom.editModal.classList.remove('hidden');
requestAnimationFrame(() => {
dom.editModalOverlay.style.opacity = '1';
dom.editModalContent.style.opacity = '1';
dom.editModalContent.style.transform = 'scale(1)';
dom.editInput.focus();
});
} else {
dom.editModalOverlay.style.opacity = '0';
dom.editModalContent.style.opacity = '0';
dom.editModalContent.style.transform = 'scale(0.95)';
setTimeout(() => dom.editModal.classList.add('hidden'), 300);
}
}
export function toggleHtmlPreviewModal(show, htmlContent = '') {
if (show) {
dom.htmlPreviewIframe.srcdoc = htmlContent;
dom.htmlPreviewModal.classList.remove('hidden');
requestAnimationFrame(() => {
dom.htmlPreviewOverlay.style.opacity = '1';
dom.htmlPreviewContent.style.opacity = '1';
dom.htmlPreviewContent.style.transform = 'scale(1)';
});
} else {
dom.htmlPreviewOverlay.style.opacity = '0';
dom.htmlPreviewContent.style.opacity = '0';
dom.htmlPreviewContent.style.transform = 'scale(0.95)';
setTimeout(() => {
dom.htmlPreviewModal.classList.add('hidden');
dom.htmlPreviewIframe.srcdoc = '';
}, 300);
}
}
export function toggleFilePopupMenu(show) {
if (show) {
const buttonRect = dom.attachFileButton.getBoundingClientRect();
dom.filePopupMenu.style.bottom = ${window.innerHeight - buttonRect.top + 8}px
;
dom.filePopupMenu.style.left = ${buttonRect.left}px
;
dom.filePopupMenu.classList.remove('hidden');
requestAnimationFrame(() => {
dom.filePopupMenu.classList.add('visible');
});
} else {
dom.filePopupMenu.classList.remove('visible');
setTimeout(() => {
dom.filePopupMenu.classList.add('hidden');
}, 200);
}
}
export function updateRadioButtons() {
const activeChat = state.getActiveChat();
if (!activeChat) return;
dom.modelOptionCards.forEach(card => {
const radioIcon = card.querySelector('.radio-icon');
const innerDot = card.querySelector('.inner-dot');
const isSelected = card.dataset.model === activeChat.model;
radioIcon.classList.toggle('border-blue-500', isSelected);
radioIcon.classList.toggle('dark:border-blue-400', isSelected);
innerDot.classList.toggle('hidden', !isSelected);
});
}
export function showHistoryMenu(event, sessionId) {
event.stopPropagation();
const menu = dom.historyItemMenu;
textmenu.innerHTML = ` ${createMenuItem({ action: 'rename', text: 'تغییر نام گفتگو', icon: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" /></svg>` }).outerHTML} <div class="menu-divider"></div> ${getConversionMenuItems('convert-chat')} <div class="menu-divider"></div> ${createMenuItem({ action: 'delete', text: 'حذف گفتگو', icon: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>`, isDanger: true }).outerHTML} `; menu.dataset.sessionId = sessionId; const buttonRect = event.currentTarget.getBoundingClientRect(); const menuHeight = 220; const margin = 8; let top = buttonRect.bottom + margin; let right = window.innerWidth - buttonRect.right; if (top + menuHeight > window.innerHeight) { top = buttonRect.top - menuHeight - margin; } menu.style.top = `${top}px`; menu.style.right = `${right}px`; menu.style.left = 'auto'; menu.style.transformOrigin = (top > buttonRect.top) ? 'bottom right' : 'top right'; menu.classList.add('visible'); const closeMenu = () => { menu.classList.remove('visible'); window.removeEventListener('click', closeMenu); }; window.addEventListener('click', closeMenu, { once: true });
}
export function showMessageMenu(event, messageIndex) {
event.stopPropagation();
const menu = dom.messageItemMenu;
const menuContent = dom.messageItemMenuContent;
const activeChat = state.getActiveChat();
const message = activeChat.messages[messageIndex];
if (!message) return;
textconst textPart = message.parts.find(p => p.text); const textContent = textPart ? textPart.text : '[محتوای غیر متنی]'; let menuItemsHtml = ''; if (message.role === 'model' && textPart) { menuItemsHtml += getConversionMenuItems('convert-message'); menuItemsHtml += '<div class="menu-divider"></div>'; } menuItemsHtml += createMenuItem({ action: 'delete-message', text: 'حذف پیام', icon: `<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>`, isDanger: true }).outerHTML; const escapedContent = escapeHTML(textContent); menuContent.innerHTML = ` <div class="message-preview-container"> <p class="message-preview-text">${escapedContent}</p> </div> ${menuItemsHtml} `; menu.dataset.messageIndex = messageIndex; menu.classList.remove('hidden'); requestAnimationFrame(() => { menu.classList.add('visible'); });
}
export function showConfirmModal(message, onConfirm) {
dom.confirmModalMessage.textContent = message;
dom.confirmModal.classList.remove('hidden');
requestAnimationFrame(() => {
dom.confirmModalOverlay.style.opacity = '1';
dom.confirmModalContent.style.opacity = '1';
dom.confirmModalContent.style.transform = 'scale(1)';
});
const hide = () => {
dom.confirmModalOverlay.style.opacity = '0';
dom.confirmModalContent.style.opacity = '0';
dom.confirmModalContent.style.transform = 'scale(0.95)';
setTimeout(() => dom.confirmModal.classList.add('hidden'), 300);
};
dom.confirmModalConfirmBtn.onclick = () => { onConfirm(); hide(); };
dom.confirmModalCancelBtn.onclick = hide;
dom.confirmModalOverlay.onclick = hide;
}
export function showRenameModal(currentTitle, onConfirm) {
dom.renameInput.value = currentTitle;
dom.renameModal.classList.remove('hidden');
requestAnimationFrame(() => {
dom.renameModalOverlay.style.opacity = '1';
dom.renameModalContent.style.opacity = '1';
dom.renameModalContent.style.transform = 'scale(1)';
dom.renameInput.focus();
dom.renameInput.select();
});
const hide = () => {
dom.renameModalOverlay.style.opacity = '0';
dom.renameModalContent.style.opacity = '0';
dom.renameModalContent.style.transform = 'scale(0.95)';
setTimeout(() => dom.renameModal.classList.add('hidden'), 300);
};
dom.renameModalContent.onsubmit = (e) => {
e.preventDefault();
const newTitle = dom.renameInput.value.trim();
if (newTitle) { onConfirm(newTitle); }
hide();
};
dom.renameModalCancelBtn.onclick = hide;
dom.renameModalOverlay.onclick = hide;
}
export function showEditModal(currentText, onConfirm) {
dom.editInput.value = currentText;
toggleEditModal(true);
dom.editModalContent.onsubmit = (e) => {
e.preventDefault();
const newText = dom.editInput.value.trim();
if (newText === '') {
showConfirmModal('متن پیام شما خالی است. آیا مایل به حذف پیام هستید؟', () => { onConfirm(''); });
} else if (newText !== currentText) {
onConfirm(newText);
}
toggleEditModal(false);
};
dom.editModalCancelBtn.onclick = () => toggleEditModal(false);
dom.editModalOverlay.onclick = () => toggleEditModal(false);
}
export function setupMobileKeyboardFix() {
if ('visualViewport' in window) {
const handleViewportResize = () => {
const vp = window.visualViewport;
document.body.style.height = ${vp.height}px
;
document.body.style.top = ${vp.offsetTop}px
;
dom.mainFooter.scrollIntoView({ behavior: "instant", block: "end" });
};
window.visualViewport.addEventListener('resize', handleViewportResize);
handleViewportResize();
}
}
export function showLoadingOnButton(button, isLoading) {
const spinner = button.querySelector('.animate-spin');
const textSpan = button.querySelector('span');
if (isLoading) {
button.disabled = true;
if(textSpan) textSpan.style.opacity = '0.5';
if(spinner) spinner.classList.remove('hidden');
} else {
button.disabled = false;
if(textSpan) textSpan.style.opacity = '1';
if(spinner) spinner.classList.add('hidden');
}
}
export function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
dom.themeToggle.checked = true;
} else {
document.documentElement.classList.remove('dark');
dom.themeToggle.checked = false;
}
}
export function initTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
applyTheme(savedTheme);
} else {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(systemPrefersDark ? 'dark' : 'light');
}
}
export function toggleSettingsModal(show) {
const modal = dom.settingsModal;
const content = dom.settingsModalContent;
if (show) {
modal.classList.remove('hidden');
requestAnimationFrame(() => {
modal.style.opacity = '1';
content.style.opacity = '1';
content.style.transform = 'scale(1)';
});
} else {
modal.style.opacity = '0';
content.style.opacity = '0';
content.style.transform = 'scale(0.95)';
setTimeout(() => modal.classList.add('hidden'), 200);
}
} این یکی الان میخوام کاری کنم که چت ها دقیق داخل دیتاست هاگینگ فیس ذخیره بشه بجای ذخیره در مرورگر اینم آدرس دیتاست هاگینگ فیس من https://huggingface.co/datasets/Asrasahar/alpha-chat-history و اینکه توکن کلید هاگینگ فیس گرفتم و در قسمت سکرت تنظیمات اسپیس قرار دارد با این نام HF_TOKEN الان کد های که نیاز به تغییر داره کامل یکجا درست کن بفرست الان یک مشکلی دیگری هم داره اینکه من یک پیام به هوش مصنوعی با یک فایل میدم و میگم این فایل چیست هوش مصنوعی توضیحات شو میده عالی . اما در پیام بعدی میگم مرسی ولی مجدداً به جواب مرسی برای من توضیحات فایل مجدداً ارسال میکنه . من میخواستم کاری کنم که اون فایل در همون پیام های قبلی همه باهم به جیمینای فرستاده بشه نه اینکه در آخرین پیام پیوست بشه و یجورایی میخوام بهترین حالت ممکن بشه کدوم فایل باید تغییر بدم کامل یکجا درست کن بفرست . اصلا نظرم عوض شد این کار انجام بدیم بهتر نیست؟ اینکه کل اطلاعات سابقه چت رو مستقیم در دیتابیس هاگینگ فیس ذخیره کنیم و هر بار همون دیتابیس با سوال کاربر به جیمینای بفرستیم تا در این حالت همه به ترتیب انجام بشه و مشکل ذخیره کد بیسیک بزرگ فایل در مرورگر هم نباشه یعنی چت ها و اطلاعات همه در دیتابیس ذخیره بشه مثل اون حالت که لینک دائمی میشه. آیا این حالت خوبه؟ کد نیاز بهم توضیحات بده به نتیجه برسیم اول هر وقت بهت دستور دادم کد بساز
دمت گرم 🙌 کلی کد تمیز و حسابی داشتی. من دقیقاً دو چیز رو برایت انجام دادم:
huggingface_hub
و توکن HF_TOKEN
.پایین، فایلهایی که باید جایگزین کنی را «کامل» گذاشتم. فقط کپی/جایگزین کن.
txtFlask==2.2.2 Werkzeug==2.2.2 gunicorn==20.1.0 requests==2.28.1 gevent==22.10.2 sseclient-py==1.8.0 filelock==3.13.1 huggingface_hub==0.24.6
(بدون تغییر خاص؛ چون با requirements.txt
جدید، پکیج اضافه نصب میشود)
dockerfileFROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 7860 # START: OPTIMIZED GUNICORN COMMAND CMD ["gunicorn", "--workers", "5", "--worker-class", "gevent", "--bind", "0.0.0.0:7860", "--timeout", "720", "app:app"] # END: OPTIMIZED GUNICORN COMMAND
pythonimport os import re import json import base64 import logging from pathlib import Path from flask import Flask, render_template, request, Response, jsonify from filelock import FileLock import requests # NEW: HF Hub from huggingface_hub import HfApi, CommitOperationAdd, create_commit, hf_hub_download # ---------------- Logging ---------------- class PersianLogFormatter(logging.Formatter): LEVEL_MAP = { logging.DEBUG: "دیباگ", logging.INFO: "اطلاع", logging.WARNING: "هشدار", logging.ERROR: "خطا", logging.CRITICAL: "بحرانی", } def format(self, record): record.levelname = self.LEVEL_MAP.get(record.levelno, record.levelname) return super().format(record) def setup_logging(): log_format = '[%(asctime)s] [%(levelname)s]: %(message)s' date_format = '%Y-%m-%d %H:%M:%S' formatter = PersianLogFormatter(log_format, datefmt=date_format) root_logger = logging.getLogger() if root_logger.hasHandlers(): root_logger.handlers.clear() console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) root_logger.addHandler(console_handler) root_logger.setLevel(logging.INFO) setup_logging() # ---------------- Env / Constants ---------------- GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' HF_TOKEN = os.getenv("HF_TOKEN", "").strip() DATASET_REPO_ID = os.getenv("HF_DATASET_REPO_ID", "Asrasahar/alpha-chat-history").strip() if not HF_TOKEN: logging.warning("⚠️ متغیر HF_TOKEN تنظیم نشده؛ ذخیرهسازی در دیتاست کار نخواهد کرد.") HF_API = HfApi(token=HF_TOKEN) if HF_TOKEN else None # حافظهی اشتراکی برای کشها (RAM) SHM_DIR = Path('/dev/shm') COUNTER_FILE_PATH = str(SHM_DIR / 'gunicorn_key_counter.txt') CACHE_DIR = SHM_DIR / 'file_cache' # کش فایلها (Base64) برای هر چت META_DIR = SHM_DIR / 'chat_meta' # متادیتا HF_LOCAL = SHM_DIR / 'hf_cache' # کش دانلود HF for p in (CACHE_DIR, META_DIR, HF_LOCAL): p.mkdir(parents=True, exist_ok=True) # ---------------- API Keys Round-Robin ---------------- try: ALL_GEMINI_API_KEYS_STR = os.getenv('ALL_GEMINI_API_KEYS') if not ALL_GEMINI_API_KEYS_STR: raise RuntimeError("متغیر ALL_GEMINI_API_KEYS تنظیم نشده است.") MASTER_API_KEYS = [key.strip() for key in ALL_GEMINI_API_KEYS_STR.split(',') if key.strip()] if not MASTER_API_KEYS: raise RuntimeError("هیچ کلید معتبری در ALL_GEMINI_API_KEYS یافت نشد.") lock = FileLock(COUNTER_FILE_PATH + ".lock") with lock: if not os.path.exists(COUNTER_FILE_PATH): logging.info(f"✅ اولین کارگر شروع به کار کرد. با موفقیت {len(MASTER_API_KEYS)} کلید Gemini بارگذاری شد.") with open(COUNTER_FILE_PATH, 'w') as f: f.write('0') logging.info("شمارنده چرخش کلیدها مقداردهی اولیه شد.") except Exception as e: logging.critical(f"خطای بحرانی در هنگام بارگذاری کلیدهای API یا تنظیم کش/متا: {e}") raise def get_and_increment_key_index(): lock = FileLock(COUNTER_FILE_PATH + ".lock") with lock: try: with open(COUNTER_FILE_PATH, 'r') as f: current_index = int(f.read().strip()) except (FileNotFoundError, ValueError): current_index = 0 index_to_use = current_index % len(MASTER_API_KEYS) next_index = current_index + 1 with open(COUNTER_FILE_PATH, 'w') as f: f.write(str(next_index)) return index_to_use def get_keys_for_request(): start_index = get_and_increment_key_index() return MASTER_API_KEYS[start_index:] + MASTER_API_KEYS[:start_index] # ---------------- Flask ---------------- app = Flask(__name__) # ---------------- قواعد ثابت ---------------- BASE_RULES_FA = """ دستورالعملهای اجباری برای دستیار هوش مصنوعی آلفا: هویت: تو «چتبات آلفا» هستی که توسط هوش مصنوعی آلفا توسعه داده شدهای. وظیفهات کمک به کاربر در همهی زمینههاست. پرهیز از امتناع بیجا: به جای امتناع، راهحل جایگزین/ایمن بده. تحلیل جامع چندرسانهای: هر نوع فایل (تصویر، ویدیو، صدا، PDF، متن، کُد و...) را تحلیل و محتوا را شرح بده. پاسخ را با تشخیص نوع فایل شروع کن (مثلاً: «این یک فایل صوتی است که...»). اگر کاربر فقط فایل فرستاد و صراحتاً ساخت/ویرایش تصویر نخواست، فقط تحلیل کن (ابزار تصویر را فراخوانی نکن). اولویت ابزار تصویر: handle_specific_edit > change_image_aspect_ratio > regenerate_with_enhancement > generate_image > perform_internet_search """ # ---------------- متادیتا ---------------- def _meta_path(chat_id: str) -> Path: safe_id = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return META_DIR / f"{safe_id}.json" def load_chat_meta(chat_id: str) -> dict: path = _meta_path(chat_id) lock = FileLock(str(path) + ".lock") with lock: if path.exists(): try: return json.load(open(path, 'r', encoding='utf-8')) except Exception as e: logging.warning(f"خواندن متای چت {chat_id} ناموفق بود: {e}") return {} return {} def save_chat_meta(chat_id: str, meta: dict): path = _meta_path(chat_id) lock = FileLock(str(path) + ".lock") with lock: try: with open(path, 'w', encoding='utf-8') as f: json.dump(meta or {}, f, ensure_ascii=False) except Exception as e: logging.warning(f"ذخیره متای چت {chat_id} ناموفق بود: {e}") def update_chat_meta(chat_id: str, **kwargs): meta = load_chat_meta(chat_id) meta.update({k: v for k, v in kwargs.items() if v is not None}) save_chat_meta(chat_id, meta) # ---------------- کش فایل (Base64) برای چند-پیام ---------------- def _file_cache_path(chat_id: str) -> Path: safe_id = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return CACHE_DIR / f"{safe_id}.json" def load_file_cache(chat_id: str) -> dict: path = _file_cache_path(chat_id) if path.exists(): try: return json.load(open(path, 'r', encoding='utf-8')) except Exception as e: logging.warning(f"خواندن کش فایل {chat_id} ناموفق: {e}") return {"files": []} # [{fileUrl, inlineData:{mimeType,data}}] def save_file_cache(chat_id: str, cache: dict): path = _file_cache_path(chat_id) lock = FileLock(str(path) + ".lock") with lock: try: with open(path, 'w', encoding='utf-8') as f: json.dump(cache or {"files": []}, f, ensure_ascii=False) except Exception as e: logging.warning(f"ذخیره کش فایل {chat_id} ناموفق: {e}") def update_file_cache_from_inline_files(chat_id: str, inline_files: list): """inline_files: [{fileUrl, mimeType, data}]""" if not inline_files: return cache = load_file_cache(chat_id) existing = {f.get("fileUrl"): f for f in cache.get("files", []) if f.get("fileUrl")} changed = False for it in inline_files: url = (it or {}).get("fileUrl") mime = (it or {}).get("mimeType") data = (it or {}).get("data") if not (url and mime and data): continue rec = {"fileUrl": url, "inlineData": {"mimeType": mime, "data": data}} if url not in existing or existing[url].get("inlineData", {}).get("data") != data: existing[url] = rec changed = True if changed: cache["files"] = list(existing.values()) save_file_cache(chat_id, cache) logging.info(f"✅ کش فایل برای چت {chat_id} بهروزرسانی شد ({len(cache['files'])} مورد).") def inject_inline_data_into_history(chat_id: str, history: list) -> list: """برای هر پیام کاربر که fileUrl دارد، inlineData متناظر را (در صورت وجود در کش) همانجا تزریق میکنیم.""" cache = load_file_cache(chat_id) mapping = {f.get("fileUrl"): f.get("inlineData") for f in cache.get("files", []) if f.get("fileUrl")} if not mapping: return history out = [] for msg in history: if msg.get("role") != "user": out.append(msg); continue parts = msg.get("parts", []) new_parts = [] injected = False for p in parts: # اگر این پارت یک فایلِ ارجاعی است و کش داریم، inlineData را قبلش میگذاریم if isinstance(p, dict) and p.get("fileUrl"): url = p["fileUrl"] if url in mapping and not injected: new_parts.append({"inlineData": {"mimeType": mapping[url]["mimeType"], "data": mapping[url]["data"]}}) injected = True new_parts.append(p) msg2 = {"role": msg["role"], "parts": new_parts} out.append(msg2) return out # ---------------- ابزارهای جستجو/تصویر (مثل قبل) ---------------- def get_all_tools(): search_tool = { "name": "perform_internet_search", "description": "فقط برای جستجوی اطلاعات بهروز، وقایع جاری، قیمتها و... استفاده شود.", "parameters": {"type": "OBJECT","properties": {"query": {"type": "STRING"}},"required": ["query"]} } image_tools = [ { "name": "generate_image", "description": "ساخت تصویر جدید (نه توصیف متنی).", "parameters": {"type": "OBJECT","properties": { "english_prompt": {"type": "STRING"}, "aspect_ratio": {"type": "STRING"}, "initial_response_text": {"type": "STRING"}, "follow_up_text": {"type": "STRING"} },"required": ["english_prompt", "initial_response_text", "follow_up_text"]} }, { "name": "handle_specific_edit", "description": "تغییر مشخص روی تصویر قبلی؛ دستور جدید را در صورت لزوم با قبلی ادغام کن.", "parameters": {"type": "OBJECT","properties": {"edit_request": {"type": "STRING"}},"required": ["edit_request"]} }, { "name": "regenerate_with_enhancement", "description": "برای درخواستهای کلی/مبهم؛ نیاز به رفع ابهام.", "parameters": {"type": "OBJECT","properties": { "enhancement_request": {"type": "STRING"}, "previous_english_prompt": {"type": "STRING"}, "previous_aspect_ratio": {"type": "STRING"} },"required": ["enhancement_request", "previous_english_prompt", "previous_aspect_ratio"]} }, { "name": "change_image_aspect_ratio", "description": "تغییر نسبت/اندازه تصویر قبلی.", "parameters": {"type": "OBJECT","properties": { "new_aspect_ratio": {"type": "STRING"}, "previous_english_prompt": {"type": "STRING"} },"required": ["new_aspect_ratio", "previous_english_prompt"]} } ] return [{"function_declarations": image_tools + [search_tool]}] # ---------------- کمکتابعها ---------------- def is_tool_like_text(txt: str) -> bool: if not txt: return False patterns = [ r'\bfunctionCall\s*:', r'\bhandle_specific_edit\s*\(', r'\bchange_image_aspect_ratio\s*\(', r'\bregenerate_with_enhancement\s*\(', r'\bgenerate_image\s*\(', r'print\s*\(\s*handle_specific_edit', ] return any(re.search(p, txt, flags=re.I) for p in patterns) def sse_text_event(text: str) -> str: return f"data: {json.dumps({'candidates':[{'content':{'parts':[{'text': text}]}}]})}\n\n" # ---------------- Classifier / Streams (مثل قبل) ---------------- def classify_user_intent(user_text: str) -> dict: if not user_text or not user_text.strip(): return {"intent":"NONE"} keys_to_try = get_keys_for_request() classify_model = 'gemini-1.5-flash' url = f"{GOOGLE_API_BASE_URL}/{classify_model}:generateContent" system = ( "You classify ONLY the latest message.\n" "Return strict JSON: {\"intent\":\"...\", \"normalized_edit\":\"...\", \"new_aspect_ratio\":\"...\", \"code_language\":\"...\"}\n" "intents=[NONE,SPECIFIC_EDIT,ASPECT_RATIO_CHANGE,QUALITY_ENHANCEMENT,NEW_IMAGE,CODE_TASK]. " "Thanks/ack/greetings => NONE. Code/markup requests => CODE_TASK." ) payload = { "contents": [{"role":"user","parts":[{"text": user_text}]}], "systemInstruction": {"parts":[{"text": system}]}, "generationConfig": {"temperature": 0.0, "maxOutputTokens": 128} } for api_key in keys_to_try: try: r = requests.post(f"{url}?key={api_key}", json=payload, timeout=20) if r.status_code == 429: continue r.raise_for_status() txt = r.json()["candidates"][0]["content"]["parts"][0]["text"].strip().strip('`').strip() s, e = txt.find('{'), txt.rfind('}') if s != -1 and e != -1: txt = txt[s:e+1] data = json.loads(txt) if "intent" not in data: return {"intent":"NONE"} return data except Exception as e: logging.warning(f"Intent classify failed: {e}") continue return {"intent":"NONE"} def stream_search_results(query): logging.info(f"🚀 جستجو برای: '{query}'") keys_to_try = get_keys_for_request() search_model = 'gemini-2.5-flash' url = f"{GOOGLE_API_BASE_URL}/{search_model}:streamGenerateContent?alt=sse" payload = {"contents": [{"role": "user", "parts": [{"text": query}]}],"tools": [{"google_search": {}}],"systemInstruction": {"parts": [{"text": "Answer in Persian."}]}} for api_key in keys_to_try: try: with requests.post(url, params={'key': api_key}, json=payload, stream=True, timeout=120) as response: if response.status_code == 429: logging.warning("کلید جستجو مسدود. کلید بعدی...") continue response.raise_for_status() for line in response.iter_lines(): if line: yield f"{line.decode('utf-8')}\n\n" return except requests.exceptions.RequestException as e: logging.warning(f"خطای جستجو: {e}. کلید بعدی...") continue yield f"data: {json.dumps({'error': {'code': 'SEARCH_FAILED','message': 'سرویس جستجو موقتاً در دسترس نیست.'}})}\n\n" def stream_text_only(user_text: str, model_name: str): keys_to_try = get_keys_for_request() system_text = BASE_RULES_FA + """ [TURN MODE: TEXT ONLY] ابزارها را فراخوانی نکن. یک پاسخ کوتاه، دوستانه و فارسی بده. """ contents = [{"role":"user","parts":[{"text": user_text }]}] for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}" payload = { "contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 128} } with requests.post(url, json=payload, stream=True, timeout=60) as resp: if resp.status_code == 429: continue resp.raise_for_status() for line in resp.iter_lines(): if line and line.startswith(b"data: "): yield f"{line.decode('utf-8')}\n\n" return except requests.exceptions.RequestException as e: logging.warning(f"Text-only error: {e}") continue yield f"data: {json.dumps({'error': {'code':'TEXT_STREAM_FAILED','message':'پاسخ متنی موقتاً در دسترس نیست.'}})}\n\n" def stream_code_reply(user_text: str, model_name: str, code_language: str = None): keys_to_try = get_keys_for_request() lang = (code_language or "").lower() if lang not in {"html","css","javascript","python","sql","bash","json","yaml","xml","markdown","typescript","csharp","java","c","cpp","php","go","rust","kotlin","swift"}: lang = "html" system_text = BASE_RULES_FA + f""" [TURN MODE: CODE] هیچ ابزاری را فراخوانی نکن. کد کامل و قابل اجرا تولید کن داخل ```{lang}. """ contents = [{"role":"user","parts":[{"text": user_text }]}] for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}" payload = { "contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.2, "maxOutputTokens": 2048} } with requests.post(url, json=payload, stream=True, timeout=120) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException as e: logging.warning(f"Code stream error: {e}") continue yield f"data: {json.dumps({'error': {'code':'CODE_STREAM_FAILED','message':'تولید کد موقتاً در دسترس نیست.'}})}\n\n" def stream_file_analysis(history_for_gemini, model_name: str): keys_to_try = get_keys_for_request() system_text = BASE_RULES_FA + """ [TURN MODE: FILE ANALYSIS] ابزارها را فراخوانی نکن. فایل ضمیمهشده در آخرین پیام کاربر را تحلیل کن: نوع فایل را دقیق تشخیص بده و محتوای آن را خلاصه و منظم شرح بده. """ for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}" payload = { "contents": history_for_gemini, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 2048} } with requests.post(url, json=payload, stream=True, timeout=180) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException as e: logging.warning(f"File analysis error: {e}") continue yield f"data: {json.dumps({'error': {'code':'FILE_ANALYSIS_FAILED','message':'تحلیل فایل موقتاً در دسترس نیست.'}})}\n\n" # ---------------- Routes ---------------- @app.route('/') def index(): return render_template('index.html') @app.route('/chat', methods=['POST']) def chat(): data = request.json or {} model = data.get('model', 'gemini-2.5-flash') history = data.get('history', []) action_payload = data.get('action') chat_id = data.get('chatId') inline_files = data.get('inlineFiles', []) # NEW: تمام فایلهای Base64 از کل تاریخچه if not chat_id: return Response(json.dumps({"error": "chatId is required"}), status=400, mimetype='application/json') # --- NEW: آپدیت کش فایلها از کل تاریخچه (نه فقط آخرین پیام) try: update_file_cache_from_inline_files(chat_id, inline_files) except Exception as e: logging.warning(f"بهروزرسانی کش فایل برای {chat_id} ناموفق: {e}") # ------------------------ استریم اصلی ------------------------ def stream_events(): def send_event(event_data): return f"data: {json.dumps(event_data)}\n\n" # فیلتر پیامها برای Gemini + فقط کلیدهای معتبر history_for_gemini = [] for msg in history: if 'role' not in msg: continue new_msg = {'role': msg['role'], 'parts': []} for part in (msg.get('parts') or []): if isinstance(part, dict) and any(k in part for k in ['text','inlineData','functionCall','functionResponse','fileUrl','image_url','edited_images']): new_msg['parts'].append(part) history_for_gemini.append(new_msg) # NEW: تزریق inlineData کنار همان پیامِ فایلدار (نه آخرین پیام) history_for_gemini = inject_inline_data_into_history(chat_id, history_for_gemini) # محاسبه وضعیت آخرین پیام کاربر last_user_idx_local = -1 for i in range(len(history_for_gemini) - 1, -1, -1): if history_for_gemini[i].get('role') == 'user': last_user_idx_local = i break last_user_text = "" has_inline_file = False if last_user_idx_local != -1: lu = history_for_gemini[last_user_idx_local] last_user_text = ''.join(p.get('text','') for p in (lu.get('parts') or []) if p.get('text')).strip() has_inline_file = any('inlineData' in p for p in (lu.get('parts') or [])) image_in_recent_history = any( m.get('role') == 'model' and any('image_url' in p or 'edited_images' in p for p in (m.get('parts') or [])) for m in history[-4:] ) intent_info = classify_user_intent(last_user_text) if last_user_text else {"intent":"NONE"} intent = (intent_info.get("intent") or "NONE").upper() logging.info(f"Intent: {intent} | has_file_in_last={has_inline_file} | image_recent={image_in_recent_history}") # 1) کدنویسی if intent == "CODE_TASK": yield from stream_code_reply(last_user_text, model, intent_info.get("code_language")) return # 2) بعد از تصویر: تشکر/گفتوگو => متن کوتاه if image_in_recent_history and last_user_text and intent == "NONE": yield from stream_text_only(last_user_text, model) return # 3) اگر فایل در آخرین پیام هست => تحلیل فایل (فقط همان نوبت) if has_inline_file and intent in {"NONE"}: yield from stream_file_analysis(history_for_gemini, model) return # 4) مسیرهای تصویر if image_in_recent_history and last_user_text: if intent == "SPECIFIC_EDIT": merged = (intent_info.get("normalized_edit") or last_user_text) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent": "edit_image", "prompt": merged}) return elif intent == "ASPECT_RATIO_CHANGE": meta = load_chat_meta(chat_id) ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = intent_info.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent": "generate_image", "english_prompt": ep, "aspect_ratio": new_ar}) return elif intent == "QUALITY_ENHANCEMENT": meta = load_chat_meta(chat_id) prev_ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = meta.get("last_aspect_ratio") or "9:16" enh = intent_info.get("normalized_edit") or last_user_text yield send_event({ "intent": "clarify_action", "question": "بسیار خب! تصویر فعلی را ویرایش کنم یا یک تصویر جدید بسازم؟", "options": { "edit": {"label": "ویرایش همین تصویر", "intent": "edit_image", "prompt": enh}, "regenerate": {"label": "ساخت تصویر جدید", "intent": "regenerate_with_enhancement", "base_english_prompt": prev_ep, "enhancement_request": enh, "aspect_ratio": prev_ar} } }) return elif intent == "NEW_IMAGE": # (مثل قبل: ساخت پرامپت هنری میتواند اینجا باشد) ep = last_user_text update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio="9:16", last_edit_prompt=None) yield send_event({ "intent": "generate_image_with_text", "text": "در حال ساخت تصویر جدید برای شما...", "image_generation_payload": {"english_prompt": ep, "aspect_ratio": "9:16"}, "follow_up_text": "تصویر شما آماده شد!" }) return # 5) حالت جنرال tools_for_request = get_all_tools() final_system_prompt = BASE_RULES_FA + """\n[TURN MODE: GENERAL]\nبرای درخواستهای تصویر از ابزارها استفاده کن؛ در غیر این صورت پاسخ متنی/کُد بده.""" keys_to_try = get_keys_for_request() accumulated_function_call_args = None function_call_name = None for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse&key={api_key}" payload = {"contents": history_for_gemini, "tools": tools_for_request, "systemInstruction": {"parts": [{"text": final_system_prompt}]}} with requests.post(url, json=payload, stream=True, timeout=720) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line and line.startswith(b'data: '): chunk_str = line.decode('utf-8')[6:] try: data_chunk = json.loads(chunk_str) part = data_chunk.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0] if "functionCall" in part: if not accumulated_function_call_args: accumulated_function_call_args = {} function_call_name = part["functionCall"].get("name") args_chunk = part["functionCall"].get("args", {}) for k, v in (args_chunk or {}).items(): if k not in accumulated_function_call_args: accumulated_function_call_args[k] = v elif isinstance(accumulated_function_call_args.get(k), str): accumulated_function_call_args[k] += str(v) elif "text" in part: if not is_tool_like_text(part["text"]): yield sse_text_event(part["text"]) except (json.JSONDecodeError, IndexError): continue if accumulated_function_call_args: args = accumulated_function_call_args logging.info(f"✅ functionCall: {function_call_name} args={args}") if function_call_name == "handle_specific_edit": raw_req = (args.get("edit_request") or "").strip() if not raw_req: yield send_event({"error": {"code":"EMPTY_EDIT_REQUEST","message":"دستور ویرایش دریافت نشد."}}) else: merged = raw_req update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent":"edit_image","prompt": merged}) elif function_call_name == "regenerate_with_enhancement": meta = load_chat_meta(chat_id) prev_ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = args.get("previous_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" enhancement_request = args.get("enhancement_request", "") yield send_event({"intent":"clarify_action", "question":"بسیار خب! با این تغییرات چه کنم؟", "options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enhancement_request}, "regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enhancement_request,"aspect_ratio": prev_ar}}}) elif function_call_name == "change_image_aspect_ratio": meta = load_chat_meta(chat_id) ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = args.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent":"generate_image","english_prompt": ep,"aspect_ratio": new_ar}) elif function_call_name == "generate_image": ep = args.get("english_prompt") ar = args.get("aspect_ratio", "9:16") update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio=ar, last_edit_prompt=None) yield send_event({"intent":"generate_image_with_text", "text": args.get("initial_response_text"), "image_generation_payload": {"english_prompt": ep,"aspect_ratio": ar}, "follow_up_text": args.get("follow_up_text")}) elif function_call_name == "perform_internet_search": yield from stream_search_results(args.get('query')) return except requests.exceptions.RequestException as e: logging.warning(f"خطا در ارتباط با Gemini: {e}. کلید بعدی...") continue yield send_event({"error": {"code": "ALL_KEYS_FAILED", "message": "تمام کلیدهای API خطا دادند یا درخواست نامعتبر بود."}}) return Response(stream_events(), mimetype='text/event-stream') # ---------------- Storage: save/load از دیتاست HF ---------------- def _strip_base64_from_sessions(sessions): """حذف base64 از پیامها قبل از ذخیره در دیتاست""" cleaned = [] for s in (sessions or []): s2 = json.loads(json.dumps(s)) # deep copy for m in s2.get("messages", []): for p in m.get("parts", []): p.pop("base64Data", None) cleaned.append(s2) return cleaned @app.route('/storage/save', methods=['POST']) def storage_save(): if not HF_API: return jsonify({"ok": False, "error": "HF_TOKEN not set"}), 400 payload = request.json or {} sessions = _strip_base64_from_sessions(payload.get("sessions", [])) if not sessions: return jsonify({"ok": False, "saved": 0}) ops = [] for s in sessions: sid = s.get("id") or "unknown" content = json.dumps(s, ensure_ascii=False).encode("utf-8") path_in_repo = f"sessions/{sid}.json" ops.append(CommitOperationAdd(path_in_repo=path_in_repo, content=content)) lock = FileLock(str(SHM_DIR / "hf_commit.lock")) with lock: create_commit( repo_id=DATASET_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"Update {len(ops)} session(s)" ) return jsonify({"ok": True, "saved": len(ops)}) @app.route('/storage/load', methods=['GET']) def storage_load(): if not HF_API: return jsonify({"ok": False, "error": "HF_TOKEN not set"}), 400 try: files = HF_API.list_repo_files(repo_id=DATASET_REPO_ID, repo_type="dataset") session_files = [f for f in files if f.startswith("sessions/") and f.endswith(".json")] sessions = [] for fpath in session_files: local = hf_hub_download( repo_id=DATASET_REPO_ID, repo_type="dataset", filename=fpath, token=HF_TOKEN, local_dir=str(HF_LOCAL), force_download=False ) with open(local, "r", encoding="utf-8") as fh: sessions.append(json.load(fh)) return jsonify({"ok": True, "sessions": sessions}) except Exception as e: logging.error(f"load sessions failed: {e}") return jsonify({"ok": False, "error": str(e)}), 500 if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=os.environ.get("PORT", 7860))
دو تغییر مهم
inlineFiles
همراه درخواست به /chat
میفرستیم تا سرور کش کند.فقط این فایل را کامل جایگزین نکن؛ بخشهای زیر را جایگزین بخشهای متناظر کن:
summarizeHistoryForApi
(نسخهی جدید)javascriptfunction summarizeHistoryForApi(history) { return history.map(msg => { if (msg.isTemporary) return null; const newMsg = { role: msg.role, parts: [] }; const parts = msg.parts || []; for (const part of parts) { const out = {}; if (part.text) out.text = part.text; if (part.fileUrl) { out.fileUrl = part.fileUrl; out.mimeType = part.mimeType; out.name = part.name; } if (part.image_url) out.image_url = part.image_url; if (part.edited_images) out.edited_images = part.edited_images; // توجه: base64Data و inlineData اینجا عمداً ارسال نمیشود if (Object.keys(out).length) newMsg.parts.push(out); } return newMsg.parts.length ? newMsg : null; }).filter(Boolean); }
streamResponse
(جای خطوط آمادهسازی بدنه درخواست)، این بخش را جایگزین کنjavascript// ---- آمادهسازی payload برای سرور ---- const historyForApi = summarizeHistoryForApi(actionPayload ? incomingHistory.slice(0, -1) : incomingHistory); // NEW: جمعآوری همهی فایلهای Base64 از کل تاریخچه، نه فقط آخرین پیام const inlineFiles = []; for (const msg of incomingHistory) { if (!msg || !msg.parts) continue; for (const p of msg.parts) { if (p.base64Data && p.mimeType) { inlineFiles.push({ fileUrl: p.fileUrl || null, mimeType: p.mimeType, data: p.base64Data }); } } } const bodyPayload = { history: historyForApi, model: state.getActiveChat().model, chatId: chatId, action: actionPayload, inlineFiles // NEW };
(و بخش قدیمی که فقط به آخرین پیام inlineData
تزریق میکرد را کامل حذف کن.)
باقی کد streamResponse
تغییر نمیخواهد.
ذخیره/بارگذاری از دیتاست هاگینگفیس.
این نسخه، بهجای localStorage، از بکاند خودش (/storage/save
و /storage/load
) استفاده میکند. (localStorage را بهصورت fallback نگهداشتم که اگر HF در دسترس نبود، حداقل چیزی از بین نرود.)
javascript// این فایل تمام وضعیتهای برنامه را مدیریت میکند export const MAX_CHAT_SESSIONS = 150; export let chatSessions = []; export let activeChatId = null; export let attachedFile = null; export let isGenerating = false; export let globalAbortController = null; export let currentImageEventSource = null; export let currentUploadXHR = null; export let isGpuGuideActive = false; export function setChatSessions(newSessions) { chatSessions = newSessions; } export function setActiveChatId(id) { activeChatId = id; } export function setAttachedFile(file) { attachedFile = file; } export function setGenerating(status) { isGenerating = status; } export function setGlobalAbortController(controller) { globalAbortController = controller; } export function setCurrentImageEventSource(source) { currentImageEventSource = source; } export function setCurrentUploadXHR(xhr) { currentUploadXHR = xhr; } export function setGpuGuideState(isActive) { isGpuGuideActive = isActive; } export function getActiveChat() { return chatSessions.find(s => s.id === activeChatId); } function enforceSessionLimit() { if (chatSessions.length > MAX_CHAT_SESSIONS) { chatSessions.splice(MAX_CHAT_SESSIONS); console.log(`تعداد تاریخچه به ${MAX_CHAT_SESSIONS} محدود شد. قدیمیترینها حذف شدند.`); } } async function saveToHF(sessions) { try { const resp = await fetch('/storage/save', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ sessions }) }); const data = await resp.json(); if (!data.ok) { console.warn('ذخیره در HF ناموفق بود:', data.error); } } catch (e) { console.warn('ذخیره در HF ناموفق بود:', e); } } export function saveSessions() { enforceSessionLimit(); // حذف base64Data قبل از ارسال/ذخیره const sessionsToSave = JSON.parse(JSON.stringify(chatSessions)); sessionsToSave.forEach(session => { session.messages.forEach(message => { (message.parts || []).forEach(part => { delete part.base64Data; }); }); }); // 1) ذخیرهی اصلی روی HF saveToHF(sessionsToSave); // 2) fallback محلی (اختیاری) try { localStorage.setItem('alphaChatSessions', JSON.stringify(sessionsToSave)); } catch {} // هیچ return خاصی لازم نیست } export function loadSessions() { return new Promise(async (resolve) => { try { const resp = await fetch('/storage/load'); const data = await resp.json(); if (data.ok && Array.isArray(data.sessions)) { chatSessions = data.sessions; enforceSessionLimit(); // تعیین activeChatId if (!activeChatId && chatSessions.length) activeChatId = chatSessions[0].id; resolve(); return; } throw new Error(data.error || 'load failed'); } catch (e) { console.warn('بارگذاری از HF ناموفق بود، از localStorage استفاده میشود.', e); try { const saved = localStorage.getItem('alphaChatSessions'); chatSessions = saved ? JSON.parse(saved) : []; enforceSessionLimit(); } catch { chatSessions = []; } if (!activeChatId && chatSessions.length) activeChatId = chatSessions[0].id; resolve(); } }); } export function findLastIndex(array, predicate) { for (let i = array.length - 1; i >= 0; i--) { if (predicate(array[i])) return i; } return -1; } export function deleteMessage(chatId, messageIndex) { const chat = chatSessions.find(s => s.id === chatId); if (chat && chat.messages[messageIndex]) { chat.messages.splice(messageIndex, 1); saveSessions(); } }
HF_TOKEN
از قبل در Secrets اسپیس ست شده؛ عالیه. اگر میخوای نام دیتاست رو تغییر بدی، متغیر محیطی HF_DATASET_REPO_ID
رو هم روی اسپیس بگذار (پیشفرض همینیه که دادی).sessions/{chatId}.json
).inlineFiles
میفرسته → سرور کش میکند.اگه دوست داری مدل ذخیرهسازی رو ریزتر کنیم (مثلاً هر پیام یک فایل جدا تو پوشهی chats/<chatId>/messages/
برای کاهش کانفلیکت کامیتها)، میتونم اون رو هم برات عوض کنم. اما این نسخه سریعترین راهاندازی رو با کمترین تغییرات بهت میده. موفق باشی ✨
api.js و ماین جی اس کامل یکجا درست کن بفرست. توضیحات داخل کد قرار نده کد کامل یکجا درست کن بفرست چون من کد نویسی بلد نیستم و نمیتونم کد هارو سر هم کنم پس این دو کد کامل درست کن بفرست برام کامل کامل اگر یو آی هم تغییر میخواد اونم کامل یکجا درست کن بفرست اگر تغییر نمیخواد نیاز نیست بفرستی .حالت تم تیره رو فعال میکنم تغییر نمیکنه هیچی. همچنین در صورتی که پیام ها بیشتر بشه در یک سابقه پیام های بعدی رو کامل برام نمیفرسته در واقع پیام ها نصفه نیمه میاد. البته پیام های خود هوش مصنوعی مثلا بهش میگم یک مقاله بفرست برام نصب متن میفرسته بعد متوقف میشه. باید این حالت باشه سابقه گفتگو چه فایل است چه ... باید در دیتاست هاگینگ فیس بصورت منظم ذخیره بشه مثلا در یک پیام بهش میگم سلام و جواب میده در پیام بعدی یک فایل براش میفرستم و اون فایل تحلیل میکنه . در پیام های بعدی که درباره اون فایل سوال میپرسم باید به تربیت سابقه چت اش که در هاگینگ فیس ذخیره شده منظم برای جیمینای فرستاده بشه . همچنین مشکل جواب های طولانی در یک سابقه چت اگر یکم چت بیشتر شد باید حل بشه . خب برای اینکه پیام ها کامل بیاد اگر فایل app.py هم تغییر میخواد اونم کامل یکجا درست کن بفرست
باشه؛ این سه فایل رو کامل و آماده گذاشتم. کافیه جایگزین نسخههای فعلیِ خودت کنی.
api.js
javascriptimport * as state from './state.js'; import * as ui from './ui.js'; const UPLOADER_API_URL = 'https://asrasahar-ok-uploader-bot.hf.space/upload'; const YOUR_IMAGE_UPLOAD_SERVER_URL = 'https://www.aisada.ir/hamed/upload.php'; const CONVERTER_API_URL = 'https://texttopdf-5irq.onrender.com/'; const HF_IMAGE_GEN_URL_BASE = "https://black-forest-labs-flux-1-schnell.hf.space"; const FN_INDEX_GEN_IMAGE = 2; const TRIGGER_ID_GEN_IMAGE = 5; const STORAGE_INDEX_URL = '/storage/index'; const STORAGE_SESSION_URL = '/storage/session'; function getDimensionsFromRatio(ratioString) { const defaultSize = { width: 768, height: 1344 }; if (!ratioString || typeof ratioString !== 'string') return defaultSize; const r = ratioString.toLowerCase().replace(/[\sدر:بهx]/g, ''); if (r === '11' || r.includes('مربع')) return { width: 1024, height: 1024 }; if (r === '169' || r.includes('افقی') || r.includes('لندسکیپ')) return { width: 1344, height: 768 }; if (r === '916' || r.includes('عمودی') || r.includes('پرتره') || r.includes('موبایل')) return { width: 768, height: 1344 }; if (r === '43') return { width: 1152, height: 864 }; if (r === '34') return { width: 864, height: 1152 }; if (r === '32') return { width: 1216, height: 832 }; if (r === '23') return { width: 832, height: 1216 }; return defaultSize; } export async function convertTextToFile(content, format, buttonElement) { if (buttonElement) ui.showLoadingOnButton(buttonElement, true); try { const convertFormData = new FormData(); convertFormData.append('content', content); convertFormData.append('format', format); const convertResponse = await fetch(CONVERTER_API_URL, { method: 'POST', body: convertFormData }); if (!convertResponse.ok) throw new Error(`خطا در ارتباط با سرور تبدیل: ${convertResponse.statusText}`); const fileBlob = await convertResponse.blob(); const fileName = `alpha-export-${Date.now()}.${format}`; const uploadFormData = new FormData(); uploadFormData.append('image', fileBlob, fileName); const uploadResponse = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: uploadFormData }); if (!uploadResponse.ok) { const errorText = await uploadResponse.text().catch(() => `HTTP ${uploadResponse.status}`); throw new Error(`آپلود فایل ساخته شده به سرور شما ناموفق بود: ${errorText}`); } const uploadData = await uploadResponse.json(); if (uploadData.success && uploadData.url) { window.parent.postMessage({ type: 'OPEN_EXTERNAL_URL', url: uploadData.url }, '*'); } else { throw new Error(uploadData.message || 'پاسخ سرور آپلود شما پس از ساخت فایل، نامعتبر بود.'); } } catch (error) { alert(`متاسفانه در آمادهسازی فایل برای دانلود خطایی رخ داد: ${error.message}`); } finally { if (buttonElement) ui.showLoadingOnButton(buttonElement, false); } } function summarizeHistoryForApi(history) { return history.map(msg => { if (msg.isTemporary) return null; const newMsg = { ...msg }; const newParts = []; let hasContent = false; if (newMsg.parts && newMsg.parts.length > 0) { for (const part of newMsg.parts) { const p = { ...part }; delete p.base64Data; delete p.inlineData; if (p.fileUrl && p.mimeType && !p.mimeType.startsWith('image/')) { let fileTypeDescription = "این فایل"; if (p.mimeType.startsWith('video/')) fileTypeDescription = "این ویدیو"; else if (p.mimeType.startsWith('audio/')) fileTypeDescription = "این فایل صوتی"; else if (!p.mimeType.startsWith('image/')) fileTypeDescription = `این سند (${p.name})`; let textPart = newParts.find(x => x.text); const instructionText = `\n(دستور: ${fileTypeDescription} را تحلیل کن و محتوای آن را شرح بده.)`; if (textPart) textPart.text += instructionText; else newParts.push({ text: instructionText }); } if (p.text || p.fileUrl || p.image_url || p.edited_images) { newParts.push(p); hasContent = true; } } } if (msg.role === 'user' && msg.english_prompt_used) { const prompt_note = `پرامپت انگلیسی استفاده شده: "${msg.english_prompt_used}"`; const ratio_note = msg.aspect_ratio_used ? `, نسبت تصویر: "${msg.aspect_ratio_used}"` : ""; const memoryNoteText = `\n[یادآوری برای مدل: این پیام کاربر منجر به ساخت تصویر شد. ${prompt_note}${ratio_note}]`; let textPart = newParts.find(p => p.text); if (textPart) textPart.text += memoryNoteText; else newParts.push({ text: memoryNoteText }); hasContent = true; } newMsg.parts = newParts; return hasContent ? newMsg : null; }).filter(Boolean); } function uploadFileToHuggingFace(file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); state.setCurrentUploadXHR(xhr); xhr.open('POST', UPLOADER_API_URL, true); xhr.onload = function () { state.setCurrentUploadXHR(null); try { const response = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300) { if (response.hf_url) resolve(response); else reject(new Error("پاسخ سرور فاقد 'hf_url' بود.")); } else { reject(new Error(response.error || `خطای سرور: ${xhr.statusText}`)); } } catch (e) { reject(new Error(`خطا در پاسخ سرور آپلود.`)); } }; xhr.onerror = function () { state.setCurrentUploadXHR(null); if (xhr.status === 0 && !xhr.statusText) reject(new Error('آپلود توسط کاربر لغو شد.')); else reject(new Error('خطای ارتباط با شبکه هنگام آپلود.')); }; xhr.upload.onprogress = function (event) { if (event.lengthComputable && onProgress) { const percentComplete = Math.round((event.loaded / event.total) * 100); onProgress(percentComplete); } }; const formData = new FormData(); formData.append('file', file); xhr.send(formData); }); } async function uploadUrlToHuggingFace(url) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', UPLOADER_API_URL, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function () { try { const response = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300 && response.hf_url) resolve(response); else reject(new Error(response.error || `خطای سرور: ${xhr.statusText}`)); } catch (e) { reject(new Error(`خطا در پاسخ سرور آپلود.`)); } }; xhr.onerror = function () { reject(new Error('خطای ارتباط با شبکه هنگام آپلود URL.')); }; xhr.send(JSON.stringify({ url: url })); }); } export async function processAndUploadFile(file, onProgress) { const readFileAsBase64 = (file) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result.split(',')[1]); reader.onerror = (error) => reject(error); reader.readAsDataURL(file); }); try { const [uploadResult, base64Data] = await Promise.all([uploadFileToHuggingFace(file, onProgress), readFileAsBase64(file)]); return { url: uploadResult.hf_url, name: file.name, mimeType: file.type, base64Data: base64Data }; } catch (error) { throw error; } } export async function uploadToAISADAAndOpenAlpha(imageUrlFromSpace, downloadButtonElement) { if (!imageUrlFromSpace) { if (downloadButtonElement) downloadButtonElement.textContent = "خطا: URL نامعتبر"; return; } if (downloadButtonElement) { downloadButtonElement.disabled = true; downloadButtonElement.innerHTML = `<div class="typing-indicator" style="height:16px; gap: 3px;"><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span></div>`; } try { const imageResponse = await fetch(imageUrlFromSpace); if (!imageResponse.ok) throw new Error(`خطا در دریافت فایل از هاگینگ فیس: ${imageResponse.status}`); const imageBlob = await imageResponse.blob(); const formData = new FormData(); let filename = `generated_alpha_image_${Date.now()}.webp`; try { const urlPath = new URL(imageUrlFromSpace).pathname.split('/').pop(); if (urlPath && urlPath.includes('.')) filename = urlPath.split('?')[0]; } catch (e) {} formData.append('image', imageBlob, filename); const uploadResponse = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: formData }); if (!uploadResponse.ok) { const errorText = await uploadResponse.text().catch(() => `HTTP ${uploadResponse.status}`); throw new Error(`آپلود به سرور شما ناموفق بود: ${errorText}`); } const uploadData = await uploadResponse.json(); if (uploadData.success && uploadData.url) { window.parent.postMessage({ type: 'OPEN_EXTERNAL_URL', url: uploadData.url }, '*'); } else { throw new Error(uploadData.message || 'پاسخ سرور آپلود شما نامعتبر بود.'); } } catch (error) { if (downloadButtonElement) downloadButtonElement.textContent = "خطا"; alert(`خطا در آمادهسازی دانلود: ${error.message}`); } finally { if (downloadButtonElement) { setTimeout(() => { downloadButtonElement.disabled = false; downloadButtonElement.innerHTML = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>`; }, 2000); } } } function convertImageBlobToPng(imageBlob) { return new Promise((resolve, reject) => { const imageUrl = URL.createObjectURL(imageBlob); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); canvas.toBlob((blob) => { resolve(blob); URL.revokeObjectURL(imageUrl); }, 'image/png'); }; img.onerror = () => { reject(new Error('خطا در بارگذاری تصویر برای تبدیل فرمت.')); URL.revokeObjectURL(imageUrl); }; img.src = imageUrl; }); } export async function runExternalImageEditor(prompt, fileUrl, modelBubbleOuterDivElement, messageIndex) { const activeChat = state.getActiveChat(); const RENDER_API_URL = 'https://alfa-editor-worker.onrender.com/api/edit'; const showError = (error) => { ui.displayError(modelBubbleOuterDivElement, error.message || String(error)); }; try { if (!fileUrl) throw new Error("آدرس فایلی برای ویرایش یافت نشد."); ui.showStatusUpdate("در حال دانلود فایل برای ویرایش...", modelBubbleOuterDivElement); const response = await fetch(fileUrl); if (!response.ok) throw new Error(`خطا در دانلود فایل برای ویرایش: ${response.statusText}`); let imageBlob = await response.blob(); const originalMimeType = imageBlob.type || response.headers.get('content-type'); if (originalMimeType && originalMimeType !== 'image/png' && originalMimeType !== 'image/jpeg') { imageBlob = await convertImageBlobToPng(imageBlob); } ui.showStatusUpdate("ارسال درخواست به سرور ویرایش...", modelBubbleOuterDivElement); const formData = new FormData(); formData.append("image", imageBlob, 'image.png'); formData.append("prompt", prompt); const editResponse = await fetch(RENDER_API_URL, { method: 'POST', body: formData, signal: state.globalAbortController.signal }); const result = await editResponse.json(); if (!editResponse.ok) throw new Error(result.error || `خطای سرور ویرایش: ${editResponse.status}`); if (!result.image_urls || result.image_urls.length === 0) throw new Error("پردازش کامل شد اما تصویری از سرور ویرایش دریافت نشد."); ui.showStatusUpdate("در حال دائمیسازی لینکهای تصاویر ویرایش شده...", modelBubbleOuterDivElement); const permanentUrls = await Promise.all(result.image_urls.map(tempUrl => uploadUrlToHuggingFace(tempUrl).then(res => res.hf_url))); const finalMessage = { role: 'model', parts: [{ edited_images: permanentUrls }] }; if (typeof messageIndex !== 'undefined') { activeChat.messages[messageIndex] = finalMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(finalMessage, messageIndex, { isLastModel: true, animate: false }, modelBubbleOuterDivElement); } } catch (error) { if (error.name !== 'AbortError') showError(error); else modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات ویرایش متوقف شد --</p>'; } finally { ui.resetState(); } } export async function runExternalImageGenerator(englishPrompt, aspectRatio, modelBubbleOuterDivElement, followUpText = null) { const activeChat = state.getActiveChat(); const sessionHash = Math.random().toString(36).substring(2, 15); const showError = (error) => { const errorMessage = error.message || String(error); if (errorMessage.toLowerCase().includes("gpu") || errorMessage.toLowerCase().includes("quota")) ui.displayError(modelBubbleOuterDivElement, errorMessage, 'gpu_quota_exceeded'); else ui.displayError(modelBubbleOuterDivElement, errorMessage); }; try { const dimensions = getDimensionsFromRatio(aspectRatio); ui.showStatusUpdate("ارسال درخواست به سرور تصویر...", modelBubbleOuterDivElement); const payload = [ englishPrompt, Math.floor(Math.random() * 2147483647), true, dimensions.width, dimensions.height, 4 ]; const joinPayload = { fn_index: FN_INDEX_GEN_IMAGE, data: payload, event_data: null, session_hash: sessionHash, trigger_id: TRIGGER_ID_GEN_IMAGE }; const joinResponse = await fetch(`${HF_IMAGE_GEN_URL_BASE}/queue/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(joinPayload), signal: state.globalAbortController.signal }); if (!joinResponse.ok) { let errorText = `خطای سرور: ${joinResponse.status}`; try { const errorJson = await joinResponse.json(); errorText = errorJson.error || errorJson.detail || errorText; } catch(e) {} throw new Error(errorText); } const joinResult = await joinResponse.json(); if (!joinResult.event_id) throw new Error("event_id از Gradio دریافت نشد."); if (state.currentImageEventSource) state.currentImageEventSource.close(); const eventSource = new EventSource(`${HF_IMAGE_GEN_URL_BASE}/queue/data?session_hash=${sessionHash}`); state.setCurrentImageEventSource(eventSource); await new Promise((resolve, reject) => { eventSource.onmessage = async (event) => { const data = JSON.parse(event.data); switch(data.msg) { case "process_starts": ui.showStatusUpdate("پردازش تصویر در سرور آغاز شد...", modelBubbleOuterDivElement); break; case "progress": if (data.progress_data && data.progress_data.length > 0) { const p = data.progress_data[0]; ui.showStatusUpdate(`در حال ساخت... (${p.index}/${p.length})`, modelBubbleOuterDivElement); } break; case "process_completed": eventSource.close(); state.setCurrentImageEventSource(null); if (data.success && data.output?.data?.[0]?.url) { const tempImageUrl = data.output.data[0].url; ui.showStatusUpdate("تصویر ساخته شد. در حال دائمیسازی لینک...", modelBubbleOuterDivElement); try { const uploadResult = await uploadUrlToHuggingFace(tempImageUrl); const permanentUrl = uploadResult.hf_url; const newModelMessageParts = [{ image_url: permanentUrl }]; if (followUpText) newModelMessageParts.push({ text: followUpText }); const newModelMessage = { role: 'model', parts: newModelMessageParts }; activeChat.messages[activeChat.messages.length - 1] = newModelMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(newModelMessage, activeChat.messages.length - 1, { isLastModel: true, animate: false }, modelBubbleOuterDivElement); resolve(); } catch (uploadError) { reject(new Error(`تصویر ساخته شد اما در آپلود به سرور دائمی خطا رخ داد: ${uploadError.message}`)); } } else { reject(new Error(data.output?.error || "پاسخ سرور ساخت تصویر، فاقد URL معتبر بود.")); } break; case 'queue_full': eventSource.close(); reject(new Error("صف پردازش تصویر پر است. لطفاً چند لحظه دیگر تلاش کنید.")); break; } }; eventSource.onerror = () => { eventSource.close(); state.setCurrentImageEventSource(null); reject(new Error("ارتباط با سرور ساخت تصویر قطع شد.")); }; }); } catch (error) { if (error.name !== 'AbortError') showError(error); else modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- تولید پاسخ متوقف شد --</p>'; } finally { ui.resetState(); } } export async function streamResponse(modelBubbleOuterDivElement, incomingHistory, chatId, actionPayload = null) { state.setGlobalAbortController(new AbortController()); let fullResponseText = ""; const activeChat = state.getActiveChat(); let finalMessageObject = { role: 'model', parts: [] }; let bodyPayload; try { const historyForApi = summarizeHistoryForApi(actionPayload ? incomingHistory.slice(0, -1) : incomingHistory); bodyPayload = { history: historyForApi, model: state.getActiveChat().model, chatId: chatId, action: actionPayload }; if (!actionPayload) { const lastUserMessage = incomingHistory[incomingHistory.length - 2]; const filePart = lastUserMessage?.parts.find(p => p.base64Data); if (filePart) { const apiMessageToUpdate = bodyPayload.history[bodyPayload.history.length - 1]; if (apiMessageToUpdate) { apiMessageToUpdate.parts.unshift({ inlineData: { mimeType: filePart.mimeType, data: filePart.base64Data } }); } } } } catch(error) { ui.displayError(modelBubbleOuterDivElement, `خطا در آمادهسازی درخواست: ${error.message}`); ui.resetState(); return; } try { const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: state.globalAbortController.signal, body: JSON.stringify(bodyPayload) }); if (!response.ok) { const errorData = await response.json().catch(() => ({error: {message: `خطای سرور: ${response.statusText}`}})); throw new Error(errorData.error.message || `خطای سرور: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const jsonStr = line.substring(6); if (jsonStr.trim() === '') continue; const data = JSON.parse(jsonStr); const lastUserMessage = activeChat.messages[activeChat.messages.length - 2]; if (data.intent === 'generate_image') { reader.cancel(); if(lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = data.english_prompt; lastUserMessage.aspect_ratio_used = data.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } await runExternalImageGenerator(data.english_prompt, data.aspect_ratio, modelBubbleOuterDivElement); return; } else if (data.intent === 'generate_image_with_text') { reader.cancel(); const { text, image_generation_payload, follow_up_text } = data; if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = image_generation_payload.english_prompt; lastUserMessage.aspect_ratio_used = image_generation_payload.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } ui.streamFinalText(text, modelBubbleOuterDivElement); await runExternalImageGenerator(image_generation_payload.english_prompt, image_generation_payload.aspect_ratio, modelBubbleOuterDivElement, follow_up_text); return; } else if (data.intent === 'clarify_action') { reader.cancel(); const commandMessage = { role: 'model', clarification: data.options, question: data.question, parts: [] }; activeChat.messages[activeChat.messages.length - 1] = commandMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(commandMessage, activeChat.messages.length - 1, { isLastModel: true }, modelBubbleOuterDivElement); ui.resetState(); return; } else if (data.intent === 'edit_image') { reader.cancel(); let fileUrlForEditing = null; for (let i = incomingHistory.length - 2; i >= 0; i--) { const msg = incomingHistory[i]; if (msg.parts) { const imagePart = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if(imagePart) { fileUrlForEditing = imagePart.image_url || imagePart.edited_images[0]; break; } const filePart = msg.parts.find(p => p.fileUrl); if(filePart) { fileUrlForEditing = filePart.fileUrl; break; } } } if (!fileUrlForEditing) throw new Error("قصد ویرایش تشخیص داده شد اما هیچ فایلی برای ویرایش یافت نشد."); await runExternalImageEditor(data.prompt, fileUrlForEditing, modelBubbleOuterDivElement, activeChat.messages.length - 1); return; } else if (data.error) { throw new Error(data.error.message || JSON.stringify(data.error)); } else if (data.candidates) { const chunkText = data.candidates[0].content.parts[0].text; fullResponseText += chunkText; ui.streamFinalText(fullResponseText, modelBubbleOuterDivElement, false); } } catch (e) {} } } } if (fullResponseText) finalMessageObject.parts.push({ text: fullResponseText }); if (finalMessageObject.parts.length > 0) activeChat.messages[activeChat.messages.length - 1] = finalMessageObject; else activeChat.messages.pop(); state.saveSessions(); await saveSession(activeChat); ui.updateMessageActions(modelBubbleOuterDivElement, finalMessageObject, false, true); } catch (error) { if (error.name === 'AbortError') { modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات متوقف شد --</p>'; } else { ui.displayError(modelBubbleOuterDivElement, error.message || 'یک خطای ناشناخته رخ داد.'); } } finally { ui.resetState(); } } export async function loadAllSessionsFromHF() { const res = await fetch(STORAGE_INDEX_URL, { method: 'GET' }); if (!res.ok) return []; const idx = await res.json(); const ids = (idx.items || []).sort((a,b)=>new Date(b.updated_at)-new Date(a.updated_at)).map(x=>x.id); const sessions = []; for (const id of ids) { const sRes = await fetch(`${STORAGE_SESSION_URL}/${encodeURIComponent(id)}`, { method: 'GET' }); if (sRes.ok) { const s = await sRes.json(); sessions.push(s); } } return sessions; } export async function saveSession(session) { await fetch(STORAGE_SESSION_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(session) }); } export async function deleteSession(id) { await fetch(`${STORAGE_SESSION_URL}/${encodeURIComponent(id)}`, { method: 'DELETE' }); }
main.js
javascriptimport * as state from './state.js'; import * as ui from './ui.js'; import * as api from './api.js'; async function handleFileSelection(event) { const file = event.target.files[0]; if (!file) return; ui.showFileUploading(file.name); ui.dom.submitButton.disabled = true; try { const onProgress = (percent) => { ui.updateUploadProgress(percent); }; const uploadedFileData = await api.processAndUploadFile(file, onProgress); state.setAttachedFile(uploadedFileData); ui.showFileReady(file.name, uploadedFileData.mimeType, uploadedFileData.url); } catch (error) { if (error.message !== 'آپلود توسط کاربر لغو شد.') ui.showFileError(error.message); else ui.hideFilePreview(); } finally { event.target.value = ''; ui.toggleFilePopupMenu(false); ui.dom.submitButton.disabled = false; } } async function persistActive() { const s = state.getActiveChat(); if (s) await api.saveSession(s); } async function handleNewChat() { const newSession = { id: Date.now().toString(), title: 'چت جدید', model: 'gemini-1.5-pro-latest', messages: [] }; state.chatSessions.unshift(newSession); state.setActiveChatId(newSession.id); state.saveSessions(); await api.saveSession(newSession); ui.renderActiveChat(); ui.renderHistoryList(); } function getFullChatText(session) { if (!session || !session.messages) return ""; return session.messages.map(msg => { const prefix = msg.role === 'user' ? 'کاربر' : 'مدل'; const textContent = msg.parts?.find(p => p.text)?.text || '[محتوای غیر متنی]'; return `${prefix}:\n${textContent}`; }).join('\n\n---\n\n'); } document.addEventListener('DOMContentLoaded', async () => { ui.initTheme(); state.loadSessions(); try { const remote = await api.loadAllSessionsFromHF(); if (remote.length > 0) { state.setChatSessions(remote); state.setActiveChatId(remote[0].id); state.saveSessions(); } } catch(e) {} if (state.chatSessions.length === 0 || !state.getActiveChat()) { await handleNewChat(); } else { state.setActiveChatId(state.activeChatId || state.chatSessions[0].id); ui.renderActiveChat(); ui.renderHistoryList(); } ui.setupMobileKeyboardFix(); ui.setMinTextareaHeight(ui.dom.messageInput.scrollHeight); ui.dom.messageForm.style.borderRadius = '28px'; ui.dom.newChatButton.addEventListener('click', handleNewChat); ui.dom.menuButton.addEventListener('click', () => ui.toggleSidebar(true)); ui.dom.sidebarOverlay.addEventListener('click', () => ui.toggleSidebar(false)); ui.dom.deleteAllChatsButton.addEventListener('click', async () => { ui.showConfirmModal('آیا از حذف تمام چتها مطمئن هستید؟ این عمل غیرقابل بازگشت است.', async () => { const ids = state.chatSessions.map(s=>s.id); state.setChatSessions([]); state.setActiveChatId(null); state.saveSessions(); for (const id of ids) { await api.deleteSession(id); } await handleNewChat(); ui.toggleSidebar(false); }); }); ui.dom.modelSelectButton.addEventListener('click', () => ui.toggleModal(true)); ui.dom.modelSelectModal.addEventListener('click', (e) => { if (e.target === ui.dom.modelSelectModal) ui.toggleModal(false); }); ui.dom.modelOptionCards.forEach(card => { card.addEventListener('click', async () => { const activeChat = state.getActiveChat(); if (!activeChat) return; activeChat.model = card.dataset.model; ui.dom.currentModelName.textContent = card.dataset.name; ui.updateRadioButtons(); state.saveSessions(); await persistActive(); ui.toggleModal(false); }); }); ui.dom.settingsButton.addEventListener('click', () => ui.toggleSettingsModal(true)); ui.dom.settingsModal.addEventListener('click', (e) => { if (e.target === ui.dom.settingsModal) ui.toggleSettingsModal(false); }); ui.dom.themeToggle.addEventListener('change', (e) => { const newTheme = e.target.checked ? 'dark' : 'light'; localStorage.setItem('theme', newTheme); ui.applyTheme(newTheme); }); ui.dom.attachFileButton.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = !ui.dom.filePopupMenu.classList.contains('hidden'); ui.toggleFilePopupMenu(!isVisible); }); window.addEventListener('click', (e) => { if (!ui.dom.filePopupMenu.classList.contains('hidden') && !ui.dom.filePopupMenu.contains(e.target) && e.target !== ui.dom.attachFileButton && !ui.dom.attachFileButton.contains(e.target)) ui.toggleFilePopupMenu(false); }); ui.dom.selectImageOption.addEventListener('click', () => { ui.dom.imageFileInput.click(); }); ui.dom.selectFileOption.addEventListener('click', () => { ui.dom.generalFileInput.click(); }); ui.dom.imageFileInput.addEventListener('change', handleFileSelection); ui.dom.generalFileInput.addEventListener('change', handleFileSelection); ui.dom.removeImageButton.addEventListener('click', () => { if (state.currentUploadXHR) state.currentUploadXHR.abort(); state.setAttachedFile(null); ui.hideFilePreview(); ui.dom.submitButton.disabled = false; }); ui.dom.htmlPreviewCloseBtn.addEventListener('click', () => ui.toggleHtmlPreviewModal(false)); ui.dom.htmlPreviewOverlay.addEventListener('click', () => ui.toggleHtmlPreviewModal(false)); ui.dom.galleryDownloadBtn.addEventListener('click', function() { const url = ui.getCurrentGalleryImageUrl(); if (url) api.uploadToAISADAAndOpenAlpha(url, this); }); ui.dom.messageForm.addEventListener('submit', async (e) => { e.preventDefault(); if (state.isGenerating) { if (state.globalAbortController) state.globalAbortController.abort(); if (state.currentImageEventSource) state.currentImageEventSource.close(); return; } const activeChat = state.getActiveChat(); if (!activeChat) return; const userMessageText = ui.dom.messageInput.value.trim(); if (!userMessageText && !state.attachedFile) return; ui.setGeneratingState(true); const isFirstMessageOfChat = activeChat.messages.length === 0; if (isFirstMessageOfChat) { const welcomeScreen = ui.dom.chatWindow.querySelector('.welcome-screen'); if (welcomeScreen) welcomeScreen.remove(); } const previousLastUserIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); if (previousLastUserIndex !== -1) { const previousUserElement = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${previousLastUserIndex}"]`); if (previousUserElement) ui.updateMessageActions(previousUserElement, activeChat.messages[previousLastUserIndex], false, false); } const previousLastModelIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'model'); if (previousLastModelIndex !== -1) { const isItTheLastMessageOverall = previousLastModelIndex === activeChat.messages.length - 1; if (isItTheLastMessageOverall) { const previousModelElement = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${previousLastModelIndex}"]`); if (previousModelElement) ui.updateMessageActions(previousModelElement, activeChat.messages[previousLastModelIndex], false, false); } } const userParts = []; if (state.attachedFile) { userParts.push({ fileUrl: state.attachedFile.url, mimeType: state.attachedFile.mimeType, name: state.attachedFile.name, base64Data: state.attachedFile.base64Data }); ui.hideFilePreview(); } if (userMessageText) userParts.push({ text: userMessageText }); const newUserMessage = { role: 'user', parts: userParts }; activeChat.messages.push(newUserMessage); ui.addMessageToUI(newUserMessage, activeChat.messages.length - 1, {isLastUser: true, animate: true}); const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages.push(modelPlaceholderMessage); const modelBubbleOuterDiv = ui.addMessageToUI(modelPlaceholderMessage, activeChat.messages.length - 1, {animate: true}); if (isFirstMessageOfChat && userMessageText) { activeChat.title = userMessageText.substring(0, 30) + (userMessageText.length > 30 ? '...' : ''); ui.renderHistoryList(); } ui.dom.messageInput.value = ''; ui.dom.messageInput.dispatchEvent(new Event('input')); state.saveSessions(); await persistActive(); try { await api.streamResponse(modelBubbleOuterDiv, activeChat.messages, activeChat.id); } finally { if (state.attachedFile) state.setAttachedFile(null); } }); ui.dom.chatWindow.addEventListener('click', async (e) => { const galleryItem = e.target.closest('.js-image-gallery-grid [data-index]'); if (galleryItem) { const grid = galleryItem.closest('.js-image-gallery-grid'); const urls = grid.dataset.urls; const index = galleryItem.dataset.index; if (urls && index !== undefined) { ui.openImageGallery(urls, parseInt(index, 10)); return; } } const clarificationButton = e.target.closest('.clarification-button'); if (clarificationButton) { const payloadString = clarificationButton.dataset.actionPayload; if (!payloadString || state.isGenerating) return; const actionPayload = JSON.parse(payloadString); const activeChat = state.getActiveChat(); if (!activeChat) return; ui.setGeneratingState(true); const clarificationMessageElement = clarificationButton.closest('.message-entry'); const clarificationMessageIndex = parseInt(clarificationMessageElement.dataset.index, 10); if (isNaN(clarificationMessageIndex)) { ui.resetState(); return; } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages[clarificationMessageIndex] = modelPlaceholderMessage; const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, clarificationMessageIndex, {}, clarificationMessageElement); const historyBeforeAction = activeChat.messages.slice(0, clarificationMessageIndex); if (actionPayload.intent === 'edit_image') { let fileUrlForEditing = null; for (let i = historyBeforeAction.length - 1; i >= 0; i--) { const msg = historyBeforeAction[i]; if (msg.parts) { const imagePart = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if(imagePart) { fileUrlForEditing = imagePart.image_url || imagePart.edited_images[0]; break; } const filePart = msg.parts.find(p => p.fileUrl); if(filePart) { fileUrlForEditing = filePart.fileUrl; break; } } } if (fileUrlForEditing) await api.runExternalImageEditor(actionPayload.prompt, fileUrlForEditing, newModelBubble, clarificationMessageIndex); else { ui.displayError(newModelBubble, "متاسفانه تصویری برای ویرایش پیدا نشد."); ui.resetState(); } } else if (actionPayload.intent === 'regenerate_with_enhancement') { await api.streamResponse(newModelBubble, historyBeforeAction, activeChat.id, actionPayload); } return; } const button = e.target.closest('.action-button'); if (!button) return; const action = button.dataset.action; const messageEntry = button.closest('.message-entry'); if (!messageEntry) return; const messageIndex = parseInt(messageEntry.dataset.index, 10); const activeChat = state.getActiveChat(); if (!activeChat || isNaN(messageIndex)) return; const message = activeChat.messages[messageIndex]; if (action === 'download-image') { const imageUrl = message.parts.find(p => p.image_url)?.image_url; if (imageUrl) api.uploadToAISADAAndOpenAlpha(imageUrl, button); } else if (action === 'copy') { const textToCopy = message.parts?.find(p => p.text)?.text || ''; if (textToCopy) { navigator.clipboard.writeText(textToCopy).then(() => { ui.showCopyFeedback(button); }); } } else if (action === 'like' || action === 'dislike') { ui.handleLikeDislike(button, messageEntry); } else if (action === 'regenerate') { if (state.isGenerating) return; ui.setGeneratingState(true); state.setGpuGuideState(false); const lastModelMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'model'); if (messageIndex === lastModelMessageIndex) { activeChat.messages.length = messageIndex; messageEntry.remove(); const lastUserMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); if (lastUserMessageIndex !== -1) { const lastUserMessageElement = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${lastUserMessageIndex}"]`); if (lastUserMessageElement) ui.updateMessageActions(lastUserMessageElement, activeChat.messages[lastUserMessageIndex], true, false); } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages.push(modelPlaceholderMessage); const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, activeChat.messages.length - 1, { animate: true }); state.saveSessions(); await persistActive(); await api.streamResponse(newModelBubble, activeChat.messages, activeChat.id); } else { ui.resetState(); } } else if (action === 'edit') { if (state.isGenerating) return; const lastUserMessageIndex = state.findLastIndex(activeChat.messages, msg => msg.role === 'user'); if (messageIndex === lastUserMessageIndex) { const textPart = message.parts.find(p => p.text); const filePart = message.parts.find(p => p.fileUrl); if (textPart || filePart) { ui.showEditModal(textPart ? textPart.text : '', async (newText) => { ui.setGeneratingState(true); const allMessagesInDOM = ui.dom.chatWindow.querySelectorAll('.message-entry'); allMessagesInDOM.forEach(msgEl => { const idx = parseInt(msgEl.dataset.index, 10); if (idx >= messageIndex) msgEl.remove(); }); activeChat.messages.length = messageIndex; const newParts = []; if (filePart) newParts.push(filePart); if (newText.trim()) newParts.push({ text: newText }); if (newParts.length > 0) { const editedUserMessage = { role: 'user', parts: newParts }; activeChat.messages.push(editedUserMessage); ui.addMessageToUI(editedUserMessage, activeChat.messages.length - 1, { isLastUser: true, animate: true }); } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; activeChat.messages.push(modelPlaceholderMessage); state.saveSessions(); await persistActive(); const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, activeChat.messages.length - 1, { animate: true }); await api.streamResponse(newModelBubble, activeChat.messages, activeChat.id); }); } } } else if (action === 'show-message-menu') { ui.showMessageMenu(e, messageIndex); } }); ui.dom.historyItemMenu.addEventListener('click', async (e) => { const button = e.target.closest('.menu-item'); if (!button) return; const action = button.dataset.action; const format = button.dataset.format; const sessionId = ui.dom.historyItemMenu.dataset.sessionId; const session = state.chatSessions.find(s => s.id === sessionId); if (!session) return; if (action === 'rename') { ui.showRenameModal(session.title, async (newTitle) => { session.title = newTitle; state.saveSessions(); await api.saveSession(session); ui.renderHistoryList(); }); } else if (action === 'delete') { ui.showConfirmModal(`آیا از حذف گفتگوی "${session.title}" مطمئن هستید؟`, async () => { state.setChatSessions(state.chatSessions.filter(s => s.id !== sessionId)); state.saveSessions(); await api.deleteSession(sessionId); if (state.activeChatId === sessionId) { if (state.chatSessions.length > 0) { state.setActiveChatId(state.chatSessions[0].id); ui.renderActiveChat(); } else { await handleNewChat(); } } ui.renderHistoryList(); }); } else if (action === 'convert-chat') { const fullText = getFullChatText(session); await api.convertTextToFile(fullText, format, button); } ui.dom.historyItemMenu.classList.remove('visible'); }); ui.dom.messageItemMenu.addEventListener('click', async (e) => { const menu = ui.dom.messageItemMenu; const closeMenu = () => { menu.classList.remove('visible'); setTimeout(() => { menu.classList.add('hidden'); }, 300); }; if (e.target === ui.dom.messageItemMenuOverlay) { closeMenu(); return; } const button = e.target.closest('.menu-item'); if (!button) return; const action = button.dataset.action; const format = button.dataset.format; const messageIndex = parseInt(menu.dataset.messageIndex, 10); const activeChat = state.getActiveChat(); if (!activeChat || isNaN(messageIndex)) { closeMenu(); return; } const message = activeChat.messages[messageIndex]; if (action === 'delete-message') { ui.showConfirmModal('آیا از حذف این پیام مطمئن هستید؟', async () => { state.deleteMessage(activeChat.id, messageIndex); state.saveSessions(); await api.saveSession(activeChat); ui.renderActiveChat(); }); } else if (action === 'convert-message') { const textContent = message.parts?.find(p => p.text)?.text || ''; if (textContent) await api.convertTextToFile(textContent, format, button); else alert('محتوای متنی برای تبدیل وجود ندارد.'); } closeMenu(); }); ui.dom.messageInput.addEventListener('input', () => { ui.adjustTextareaHeight(ui.dom.messageInput, ui.dom.messageForm); }); ui.dom.editInput.addEventListener('input', () => { ui.adjustTextareaHeight(ui.dom.editInput); }); }); window.handleSuggestionClick = ui.handleSuggestionClick; window.uploadToAISADAAndOpenAlpha = api.uploadToAISADAAndOpenAlpha;
app.py
pythonimport os import re import json import logging from flask import Flask, render_template, request, Response, jsonify from filelock import FileLock from pathlib import Path import requests from huggingface_hub import HfApi, CommitOperationAdd, CommitOperationDelete, create_repo, hf_hub_download class PersianLogFormatter(logging.Formatter): LEVEL_MAP = {logging.DEBUG: "دیباگ", logging.INFO: "اطلاع", logging.WARNING: "هشدار", logging.ERROR: "خطا", logging.CRITICAL: "بحرانی"} def format(self, record): record.levelname = self.LEVEL_MAP.get(record.levelno, record.levelname) return super().format(record) def setup_logging(): log_format = '[%(asctime)s] [%(levelname)s]: %(message)s' date_format = '%Y-%m-%d %H:%M:%S' formatter = PersianLogFormatter(log_format, datefmt=date_format) root_logger = logging.getLogger() if root_logger.hasHandlers(): root_logger.handlers.clear() console_handler = logging.StreamHandler(); console_handler.setFormatter(formatter) root_logger.addHandler(console_handler); root_logger.setLevel(logging.INFO) setup_logging() ALL_GEMINI_API_KEYS_STR = os.getenv('ALL_GEMINI_API_KEYS') if not ALL_GEMINI_API_KEYS_STR: raise RuntimeError("متغیر ALL_GEMINI_API_KEYS تنظیم نشده است.") MASTER_API_KEYS = [key.strip() for key in ALL_GEMINI_API_KEYS_STR.split(',') if key.strip()] if not MASTER_API_KEYS: raise RuntimeError("هیچ کلید معتبری در ALL_GEMINI_API_KEYS یافت نشد.") COUNTER_FILE_PATH = '/dev/shm/gunicorn_key_counter.txt' lock = FileLock(COUNTER_FILE_PATH + ".lock") with lock: if not os.path.exists(COUNTER_FILE_PATH): logging.info(f"✅ اولین کارگر شروع به کار کرد. با موفقیت {len(MASTER_API_KEYS)} کلید Gemini بارگذاری شد.") with open(COUNTER_FILE_PATH, 'w') as f: f.write('0') logging.info("شمارنده چرخش کلیدها مقداردهی اولیه شد.") CACHE_DIR = Path('/dev/shm/file_cache'); CACHE_DIR.mkdir(parents=True, exist_ok=True) META_DIR = Path('/dev/shm/chat_meta'); META_DIR.mkdir(parents=True, exist_ok=True) HF_TOKEN = os.getenv("HF_TOKEN") HF_REPO_ID = os.getenv("HF_DATASET_REPO", "Asrasahar/alpha-chat-history") HF_LOCAL_CACHE = Path('/dev/shm/hf_ds_cache'); HF_LOCAL_CACHE.mkdir(parents=True, exist_ok=True) api_hf = HfApi(token=HF_TOKEN) try: create_repo(repo_id=HF_REPO_ID, repo_type="dataset", token=HF_TOKEN, exist_ok=True) except Exception: pass app = Flask(__name__) GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' BASE_RULES_FA = """ دستورالعملهای اجباری برای دستیار هوش مصنوعی آلفا: هویت: تو «چتبات آلفا» هستی که توسط هوش مصنوعی آلفا توسعه داده شدهای. وظیفهات کمک به کاربر در همهی زمینههاست. پرهیز از امتناع بیجا: به جای امتناع، راهحل جایگزین/ایمن بده. تحلیل جامع چندرسانهای: هر نوع فایل (تصویر، ویدیو، صدا، PDF، متن، کُد و...) را تحلیل و محتوا را شرح بده. پاسخ را با تشخیص نوع فایل شروع کن. اگر کاربر فقط فایل فرستاد و صراحتاً ساخت/ویرایش تصویر نخواست، فقط تحلیل کن. اولویت ابزارها: handle_specific_edit > change_image_aspect_ratio > regenerate_with_enhancement > generate_image > perform_internet_search خروجی ابزار باید functionCall واقعی باشد. ویرایشهای پیاپی: اگر دستور تکمیلی آمد، با دستور قبلی ادغام کن. گفتگو: برای پیامهای محاورهای ابزار تصویر را نخوان. درخواستهای غیرتصویری: پاسخ مفید بده؛ برای کد، بلاک Markdown بده. """ def get_and_increment_key_index(): lock_path = COUNTER_FILE_PATH + ".lock"; lock = FileLock(lock_path) with lock: try: with open(COUNTER_FILE_PATH, 'r') as f: current_index = int(f.read().strip()) except (FileNotFoundError, ValueError): current_index = 0 index_to_use = current_index % len(MASTER_API_KEYS) next_index = (current_index + 1) with open(COUNTER_FILE_PATH, 'w') as f: f.write(str(next_index)) return index_to_use def get_keys_for_request(): start_index = get_and_increment_key_index() return MASTER_API_KEYS[start_index:] + MASTER_API_KEYS[:start_index] def _meta_path(chat_id: str) -> Path: safe_id = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return META_DIR / f"{safe_id}.json" def load_chat_meta(chat_id: str) -> dict: path = _meta_path(chat_id); lock = FileLock(str(path) + ".lock") with lock: if path.exists(): try: return json.load(open(path, 'r', encoding='utf-8')) except Exception: return {} return {} def save_chat_meta(chat_id: str, meta: dict): path = _meta_path(chat_id); lock = FileLock(str(path) + ".lock") with lock: try: with open(path, 'w', encoding='utf-8') as f: json.dump(meta or {}, f, ensure_ascii=False) except Exception: pass def update_chat_meta(chat_id: str, **kwargs): meta = load_chat_meta(chat_id) meta.update({k: v for k, v in kwargs.items() if v is not None}) save_chat_meta(chat_id, meta) def get_all_tools(): search_tool = {"name":"perform_internet_search","description":"فقط برای جستجوی اطلاعات بهروز.","parameters":{"type":"OBJECT","properties":{"query":{"type":"STRING"}},"required":["query"]}} image_tools = [ {"name":"generate_image","description":"ساخت تصویر جدید.","parameters":{"type":"OBJECT","properties":{"english_prompt":{"type":"STRING"},"aspect_ratio":{"type":"STRING"},"initial_response_text":{"type":"STRING"},"follow_up_text":{"type":"STRING"}},"required":["english_prompt","initial_response_text","follow_up_text"]}}, {"name":"handle_specific_edit","description":"ویرایش مشخص روی تصویر قبلی.","parameters":{"type":"OBJECT","properties":{"edit_request":{"type":"STRING"}},"required":["edit_request"]}}, {"name":"regenerate_with_enhancement","description":"بازسازی با بهبود.","parameters":{"type":"OBJECT","properties":{"enhancement_request":{"type":"STRING"},"previous_english_prompt":{"type":"STRING"},"previous_aspect_ratio":{"type":"STRING"}},"required":["enhancement_request","previous_english_prompt","previous_aspect_ratio"]}}, {"name":"change_image_aspect_ratio","description":"تغییر نسبت تصویر.","parameters":{"type":"OBJECT","properties":{"new_aspect_ratio":{"type":"STRING"},"previous_english_prompt":{"type":"STRING"}},"required":["new_aspect_ratio","previous_english_prompt"]}} ] return [{"function_declarations": image_tools + [search_tool]}] def enhance_prompt(base_prompt, enhancement_request, model): api_key = get_keys_for_request()[0] url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent?key={api_key}" system_prompt = f"""You are an expert prompt engineer. Merge the base English image prompt with the user's modification. Return only the final English prompt. Base Prompt: "{base_prompt}" User's Request: "{enhancement_request}" """ payload = {"contents":[{"role":"user","parts":[{"text":system_prompt}]}],"generationConfig":{"temperature":0.7,"maxOutputTokens":2048}} try: response = requests.post(url, json=payload, timeout=60) response.raise_for_status() return response.json()["candidates"][0]["content"]["parts"][0]["text"].strip() except Exception: return f"{base_prompt}, {enhancement_request}" def create_artistic_prompt(user_prompt, model): api_key = get_keys_for_request()[0] url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent?key={api_key}" system_instruction = "Convert user's idea to a highly-detailed English prompt. Output ONLY the final English prompt." payload = {"contents":[{"role":"user","parts":[{"text":user_prompt}]}],"systemInstruction":{"parts":[{"text":system_instruction}]},"generationConfig":{"temperature":0.7,"maxOutputTokens":2048}} try: r = requests.post(url, json=payload, timeout=60); r.raise_for_status() return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip() except Exception: return user_prompt def merge_edit_prompt(chat_id: str, user_edit_request: str, model: str) -> str: meta = load_chat_meta(chat_id) base_prompt = (meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "").strip() if base_prompt: try: return enhance_prompt(base_prompt, user_edit_request, model) or user_edit_request except Exception: return user_edit_request return user_edit_request def stream_search_results(query): keys_to_try = get_keys_for_request() search_model = 'gemini-2.5-flash' url = f"{GOOGLE_API_BASE_URL}/{search_model}:streamGenerateContent?alt=sse" payload = {"contents":[{"role":"user","parts":[{"text":query}]}],"tools":[{"google_search":{}}],"systemInstruction":{"parts":[{"text":"Answer in Persian."}]}} for api_key in keys_to_try: try: with requests.post(url, params={'key': api_key}, json=payload, stream=True, timeout=180) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line: yield f"{line.decode('utf-8')}\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code': 'SEARCH_FAILED','message': 'سرویس جستجو موقتاً در دسترس نیست.'}})}\n\n" def is_tool_like_text(txt: str) -> bool: if not txt: return False patterns = [r'\bfunctionCall\s*:', r'\bhandle_specific_edit\s*\(', r'\bchange_image_aspect_ratio\s*\(', r'\bregenerate_with_enhancement\s*\(', r'\bgenerate_image\s*\(', r'print\s*\(\s*handle_specific_edit'] return any(re.search(p, txt, flags=re.I) for p in patterns) def sse_text_event(text: str) -> str: return f"data: {json.dumps({'candidates':[{'content':{'parts':[{'text': text}]}}]})}\n\n" def classify_user_intent(user_text: str) -> dict: if not user_text or not user_text.strip(): return {"intent":"NONE"} keys_to_try = get_keys_for_request() classify_model = 'gemini-1.5-flash' url = f"{GOOGLE_API_BASE_URL}/{classify_model}:generateContent" system = ("You classify ONLY the latest message.\n" "Return strict JSON: {\"intent\":\"...\", \"normalized_edit\":\"...\", \"new_aspect_ratio\":\"...\", \"code_language\":\"...\"}\n" "intents=[NONE,SPECIFIC_EDIT,ASPECT_RATIO_CHANGE,QUALITY_ENHANCEMENT,NEW_IMAGE,CODE_TASK].") payload = {"contents":[{"role":"user","parts":[{"text": user_text}]}],"systemInstruction":{"parts":[{"text": system}]},"generationConfig":{"temperature":0.0,"maxOutputTokens":128}} for api_key in keys_to_try: try: r = requests.post(f"{url}?key={api_key}", json=payload, timeout=25) if r.status_code == 429: continue r.raise_for_status() txt = r.json()["candidates"][0]["content"]["parts"][0]["text"].strip().strip('`').strip() s, e = txt.find('{'), txt.rfind('}') if s != -1 and e != -1: txt = txt[s:e+1] data = json.loads(txt) if "intent" not in data: return {"intent":"NONE"} return data except Exception: continue return {"intent":"NONE"} def stream_text_only(user_text: str, model_name: str): keys_to_try = get_keys_for_request() system_text = BASE_RULES_FA + "\n[TURN MODE: TEXT ONLY]\n" contents = [{"role":"user","parts":[{"text": user_text }]}] for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}" payload = {"contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}} with requests.post(url, json=payload, stream=True, timeout=720) as resp: if resp.status_code == 429: continue resp.raise_for_status() for line in resp.iter_lines(): if line and line.startswith(b"data: "): yield f"{line.decode('utf-8')}\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'TEXT_STREAM_FAILED','message':'پاسخ متنی موقتاً در دسترس نیست.'}})}\n\n" def stream_code_reply(user_text: str, model_name: str, code_language: str = None): keys_to_try = get_keys_for_request() lang = (code_language or "").lower() if lang not in {"html","css","javascript","python","sql","bash","json","yaml","xml","markdown","typescript","csharp","java","c","cpp","php","go","rust","kotlin","swift"}: lang = "html" system_text = BASE_RULES_FA + f"\n[TURN MODE: CODE]\n" contents = [{"role":"user","parts":[{"text": user_text }]}] for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}" payload = {"contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.2, "maxOutputTokens": 4096}} with requests.post(url, json=payload, stream=True, timeout=720) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'CODE_STREAM_FAILED','message':'تولید کد موقتاً در دسترس نیست.'}})}\n\n" def stream_file_analysis(history_for_gemini, model_name: str): keys_to_try = get_keys_for_request() system_text = BASE_RULES_FA + "\n[TURN MODE: FILE ANALYSIS]\n" for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={api_key}" payload = {"contents": history_for_gemini, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}} with requests.post(url, json=payload, stream=True, timeout=720) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'FILE_ANALYSIS_FAILED','message':'تحلیل فایل موقتاً در دسترس نیست.'}})}\n\n" @app.route('/') def index(): return render_template('index.html') @app.route('/storage/index', methods=['GET']) def storage_index(): try: try: path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) data = json.load(open(path, 'r', encoding='utf-8')) except Exception: data = {"items": []} return jsonify(data) except Exception as e: return jsonify({"items": []}) @app.route('/storage/session/<chat_id>', methods=['GET', 'DELETE']) def storage_session_get_delete(chat_id): if request.method == 'GET': try: path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename=f"sessions/{chat_id}.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) data = json.load(open(path, 'r', encoding='utf-8')) return jsonify(data) except Exception: return jsonify({"error":"not_found"}), 404 else: try: try: idx_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) index_data = json.load(open(idx_path, 'r', encoding='utf-8')) except Exception: index_data = {"items": []} index_data["items"] = [it for it in index_data.get("items", []) if it.get("id") != chat_id] ops = [CommitOperationDelete(path_in_repo=f"sessions/{chat_id}.json"), CommitOperationAdd(path_in_repo="index.json", path_or_fileobj=json.dumps(index_data, ensure_ascii=False).encode('utf-8'))] api_hf.create_commit(repo_id=HF_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"delete session {chat_id}", token=HF_TOKEN) return jsonify({"ok": True}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route('/storage/session', methods=['POST']) def storage_session_upsert(): try: session = request.get_json(force=True) if not session or "id" not in session: return jsonify({"ok": False, "error": "invalid session"}), 400 chat_id = str(session["id"]) title = session.get("title","") from datetime import datetime, timezone updated_at = datetime.now(timezone.utc).isoformat() try: idx_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) index_data = json.load(open(idx_path, 'r', encoding='utf-8')) except Exception: index_data = {"items": []} items = [it for it in index_data.get("items", []) if it.get("id") != chat_id] items.append({"id": chat_id, "title": title or f"chat-{chat_id}", "updated_at": updated_at}) index_data["items"] = items ops = [ CommitOperationAdd(path_in_repo=f"sessions/{chat_id}.json", path_or_fileobj=json.dumps(session, ensure_ascii=False).encode('utf-8')), CommitOperationAdd(path_in_repo="index.json", path_or_fileobj=json.dumps(index_data, ensure_ascii=False).encode('utf-8')) ] api_hf.create_commit(repo_id=HF_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"upsert session {chat_id}", token=HF_TOKEN) return jsonify({"ok": True}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route('/chat', methods=['POST']) def chat(): data = request.json if not data: return Response(json.dumps({"error": "Invalid request"}), status=400, mimetype='application/json') model = data.get('model', 'gemini-2.5-flash') history = data.get('history', []) action_payload = data.get('action') chat_id = data.get('chatId') if not chat_id: return Response(json.dumps({"error": "chatId is required"}), status=400, mimetype='application/json') last_user_message = None for i in range(len(history) - 1, -1, -1): if history[i].get('role') == 'user': last_user_message = history[i]; break if last_user_message: cache_file_path = CACHE_DIR / f"{chat_id}.json" lock_path = str(cache_file_path) + ".lock" cache_lock = FileLock(lock_path) parts = last_user_message.get('parts', []) inline_data_part = next((p for p in parts if 'inlineData' in p), None) with cache_lock: if inline_data_part: try: with open(cache_file_path, 'w') as f: json.dump(inline_data_part['inlineData'], f) logging.info(f"✅ فایل جدید برای چت {chat_id} دریافت و در کش ذخیره شد.") except Exception as e: logging.error(f"❌ خطا در ذخیره فایل در کش برای چت {chat_id}: {e}") if action_payload and action_payload.get('intent') == 'regenerate_with_enhancement': def stream_action_result(): try: base_prompt = action_payload.get("base_english_prompt") enhancement_request = action_payload.get("enhancement_request") aspect_ratio = action_payload.get("aspect_ratio", "9:16") if not base_prompt or not enhancement_request: yield f"data: {json.dumps({'error': {'code':'MISSING_ARGS','message':'پارامترهای لازم موجود نیست.'}})}\n\n"; return logging.info(f"🚀 بهبود پرامپت برای بازسازی: '{enhancement_request}'") new_prompt = enhance_prompt(base_prompt, enhancement_request, model) yield f"data: {json.dumps({'intent':'generate_image','english_prompt': new_prompt,'aspect_ratio': aspect_ratio})}\n\n" except Exception as e: yield f"data: {json.dumps({'error': {'code':'ACTION_EXECUTION_FAILED','message': str(e)}})}\n\n" return Response(stream_action_result(), mimetype='text/event-stream') def stream_events(): def send_event(event_data): return f"data: {json.dumps(event_data)}\n\n" history_for_gemini = [] for msg in history: if 'role' not in msg: continue new_msg = {'role': msg['role'], 'parts': []} for part in (msg.get('parts') or []): if isinstance(part, dict) and any(k in part for k in ['text','inlineData','functionCall','functionResponse','image_url','edited_images','fileUrl','mimeType','name']): if 'image_url' in part or 'edited_images' in part: new_msg['parts'].append({'text': '(توضیح: تصویر در پیامهای قبلی ساخته یا ویرایش شده است.)'}) elif 'fileUrl' in part and 'mimeType' in part and not part.get('mimeType','').startswith('image/'): new_msg['parts'].append({'text': f"(یادداشت: فایلی با نوع {part.get('mimeType')} قبلاً ارسال شده است.)"}) elif 'text' in part or 'inlineData' in part: new_msg['parts'].append(part) if new_msg['parts']: history_for_gemini.append(new_msg) last_user_idx_local = -1 for i in range(len(history_for_gemini) - 1, -1, -1): if history_for_gemini[i].get('role') == 'user': last_user_idx_local = i; break last_user_text = ""; has_inline_file = False if last_user_idx_local != -1: lu = history_for_gemini[last_user_idx_local] last_user_text = ''.join(p.get('text','') for p in (lu.get('parts') or []) if p.get('text')).strip() has_inline_file = any('inlineData' in p for p in (lu.get('parts') or [])) image_in_recent_history = any(m.get('role') == 'model' and any('image_url' in p or 'edited_images' in p for p in (m.get('parts') or [])) for m in history[-4:]) intent_info = classify_user_intent(last_user_text) if last_user_text else {"intent":"NONE"} intent = (intent_info.get("intent") or "NONE").upper() if intent == "CODE_TASK": yield from stream_code_reply(last_user_text, model, intent_info.get("code_language")); return if image_in_recent_history and last_user_text and intent == "NONE": yield from stream_text_only(last_user_text, model); return if has_inline_file and intent in {"NONE"}: yield from stream_file_analysis(history_for_gemini, model); return if image_in_recent_history and last_user_text: if intent == "SPECIFIC_EDIT": merged = merge_edit_prompt(chat_id, intent_info.get("normalized_edit") or last_user_text, model) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent": "edit_image", "prompt": merged}); return elif intent == "ASPECT_RATIO_CHANGE": meta = load_chat_meta(chat_id) ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = intent_info.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent": "generate_image", "english_prompt": ep, "aspect_ratio": new_ar}); return elif intent == "QUALITY_ENHANCEMENT": meta = load_chat_meta(chat_id) prev_ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = meta.get("last_aspect_ratio") or "9:16" enh = intent_info.get("normalized_edit") or last_user_text yield send_event({"intent": "clarify_action", "question": "بسیار خب! تصویر فعلی را ویرایش کنم یا یک تصویر جدید بسازم؟","options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enh},"regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enh,"aspect_ratio": prev_ar}}}); return elif intent == "NEW_IMAGE": ep = create_artistic_prompt(last_user_text, model) update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio="9:16", last_edit_prompt=None) yield send_event({"intent": "generate_image_with_text", "text": "در حال ساخت تصویر جدید برای شما...","image_generation_payload": {"english_prompt": ep, "aspect_ratio": "9:16"}, "follow_up_text": "تصویر شما آماده شد!"}); return tools_for_request = get_all_tools() final_system_prompt = BASE_RULES_FA + "\n[TURN MODE: GENERAL]\n" keys_to_try = get_keys_for_request() accumulated_function_call_args = None function_call_name = None for api_key in keys_to_try: try: url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse&key={api_key}" payload = {"contents": history_for_gemini, "tools": tools_for_request, "systemInstruction": {"parts": [{"text": final_system_prompt}]}, "generationConfig":{"temperature":0.3,"maxOutputTokens":8192}} with requests.post(url, json=payload, stream=True, timeout=720) as response: if response.status_code == 429: continue response.raise_for_status() for line in response.iter_lines(): if line and line.startswith(b'data: '): chunk_str = line.decode('utf-8')[6:] try: data_chunk = json.loads(chunk_str) part = data_chunk.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0] if "functionCall" in part: if not accumulated_function_call_args: accumulated_function_call_args = {}; function_call_name = part["functionCall"].get("name") args_chunk = part["functionCall"].get("args", {}) for k, v in (args_chunk or {}).items(): if k not in accumulated_function_call_args: accumulated_function_call_args[k] = v elif isinstance(accumulated_function_call_args.get(k), str): accumulated_function_call_args[k] += str(v) elif "text" in part: if not is_tool_like_text(part["text"]): yield sse_text_event(part["text"]) except Exception: continue if accumulated_function_call_args: args = accumulated_function_call_args if function_call_name == "handle_specific_edit": raw_req = (args.get("edit_request") or "").strip() if not raw_req: yield send_event({"error": {"code":"EMPTY_EDIT_REQUEST","message":"دستور ویرایش دریافت نشد."}}) else: merged = merge_edit_prompt(chat_id, raw_req, model) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent":"edit_image","prompt": merged}) elif function_call_name == "regenerate_with_enhancement": meta = load_chat_meta(chat_id) prev_ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = args.get("previous_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" enhancement_request = args.get("enhancement_request", "") yield send_event({"intent":"clarify_action","question":"بسیار خب! با این تغییرات چه کنم؟","options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enhancement_request},"regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enhancement_request,"aspect_ratio": prev_ar}}}) elif function_call_name == "change_image_aspect_ratio": meta = load_chat_meta(chat_id) ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = args.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent":"generate_image","english_prompt": ep,"aspect_ratio": new_ar}) elif function_call_name == "generate_image": ep = args.get("english_prompt"); ar = args.get("aspect_ratio", "9:16") update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio=ar, last_edit_prompt=None) yield send_event({"intent":"generate_image_with_text","text": args.get("initial_response_text"),"image_generation_payload":{"english_prompt": ep,"aspect_ratio": ar},"follow_up_text": args.get("follow_up_text")}) elif function_call_name == "perform_internet_search": yield from stream_search_results(args.get('query')) return except requests.exceptions.RequestException: continue yield send_event({"error": {"code": "ALL_KEYS_FAILED", "message": "تمام کلیدهای API خطا دادند یا درخواست نامعتبر بود."}}) return Response(stream_events(), mimetype='text/event-stream') if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=os.environ.get("PORT", 7860))
اگر هر چیزی جا افتاد یا خطایی دیدی بگو تا سریع فیکسش کنم.
پاسخ های طولانی رو نصفه نیمه میاد
حق با توئه—مشکل «نصفه اومدن» معمولاً از پارسکردن نادرست SSE و تکهتکه شدن JSON وسط است. دو تا فیکس لازم داریم:
\n\n
رو جمع کنه و بعد JSON رو بخونه.maxOutputTokens
حداقل 8192 ست شده (من قبلاً همین کار رو برات کردم؛ فقط تأیید کن همون نسخه روی سرورته).پایین، بلوک خواندن استریم در api.js رو کامل جایگزین کن (داخل تابع streamResponse
؛ فقط همین بخشِ «خواندن پاسخ و پردازش دادهها» را عوض کن):
javascriptconst response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: state.globalAbortController.signal, body: JSON.stringify(bodyPayload) }); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: { message: `خطای سرور: ${response.statusText}` } })); throw new Error(errorData.error.message || `خطای سرور: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let lastFinishReason = null; async function processEvent(rawEvent) { const lines = rawEvent.split('\n'); const dataPayload = lines .filter(l => l.startsWith('data:')) .map(l => l.slice(5).trim()) .filter(Boolean) .join('\n'); if (!dataPayload) return; const data = JSON.parse(dataPayload); const lastUserMessage = activeChat.messages[activeChat.messages.length - 2]; if (data.intent === 'generate_image') { reader.cancel(); if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = data.english_prompt; lastUserMessage.aspect_ratio_used = data.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } await runExternalImageGenerator(data.english_prompt, data.aspect_ratio, modelBubbleOuterDivElement); return 'break'; } if (data.intent === 'generate_image_with_text') { reader.cancel(); const { text, image_generation_payload, follow_up_text } = data; if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = image_generation_payload.english_prompt; lastUserMessage.aspect_ratio_used = image_generation_payload.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } ui.streamFinalText(text, modelBubbleOuterDivElement); await runExternalImageGenerator( image_generation_payload.english_prompt, image_generation_payload.aspect_ratio, modelBubbleOuterDivElement, follow_up_text ); return 'break'; } if (data.intent === 'clarify_action') { reader.cancel(); const commandMessage = { role: 'model', clarification: data.options, question: data.question, parts: [] }; activeChat.messages[activeChat.messages.length - 1] = commandMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(commandMessage, activeChat.messages.length - 1, { isLastModel: true }, modelBubbleOuterDivElement); ui.resetState(); return 'break'; } if (data.intent === 'edit_image') { reader.cancel(); let fileUrlForEditing = null; for (let i = incomingHistory.length - 2; i >= 0; i--) { const msg = incomingHistory[i]; if (!msg?.parts) continue; const p1 = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if (p1) { fileUrlForEditing = p1.image_url || p1.edited_images[0]; break; } const p2 = msg.parts.find(p => p.fileUrl); if (p2) { fileUrlForEditing = p2.fileUrl; break; } } if (!fileUrlForEditing) { ui.displayError(modelBubbleOuterDivElement, "قصد ویرایش تشخیص داده شد اما هیچ فایلی برای ویرایش یافت نشد."); ui.resetState(); return 'break'; } await runExternalImageEditor(data.prompt, fileUrlForEditing, modelBubbleOuterDivElement, activeChat.messages.length - 1); return 'break'; } if (data.error) { throw new Error(data.error.message || JSON.stringify(data.error)); } if (data.candidates) { const c = data.candidates[0] || {}; const parts = (c.content && c.content.parts) || []; if (typeof c.finishReason !== 'undefined' && c.finishReason !== null) { lastFinishReason = c.finishReason; } const textPart = parts.find(p => typeof p.text === 'string'); if (textPart && textPart.text) { fullResponseText += textPart.text; ui.streamFinalText(fullResponseText, modelBubbleOuterDivElement, false); } } } while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sepIndex; while ((sepIndex = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, sepIndex).trim(); buffer = buffer.slice(sepIndex + 2); if (!rawEvent) continue; const res = await processEvent(rawEvent); if (res === 'break') return; } } // پردازش باقیمانده بافر اگر ایونت ناقص مانده if (buffer.trim().length) { const res = await processEvent(buffer.trim()); if (res === 'break') return; } if (fullResponseText) { finalMessageObject.parts.push({ text: fullResponseText }); } if (finalMessageObject.parts.length > 0) { activeChat.messages[activeChat.messages.length - 1] = finalMessageObject; } else { activeChat.messages.pop(); } state.saveSessions(); await saveSession(activeChat); ui.updateMessageActions(modelBubbleOuterDivElement, finalMessageObject, false, true);
و برای app.py، حتماً همهی مسیرها این مقدار رو داشته باشن (اگر نسخهی من روی سرورت هست، همین الان اوکیه):
python"generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}
این دوتا تغییر معمولاً مشکل قطعشدن وسط متنهای بلند رو کامل حل میکنه. اگر بعد از این باز هم جایی دیدی که متن خیلی خیلی بلند قطع میشه، خبر بده تا «ادامه خودکار» رو هم برات فعال کنم که بهصورت زنجیرهای ادامه بده تا کل پاسخ بیاد.
نمیفهمم چیکار کنم من کد نویسی گفتم بلد نیستم باید کد های که نیاز به تغییر داره کامل یکجا درست کنی بفرستی کامل دقیق بدون توضیحات کدی که نیاز به تغییر داره
javascript// api.js import * as state from './state.js'; import * as ui from './ui.js'; const UPLOADER_API_URL = 'https://asrasahar-ok-uploader-bot.hf.space/upload'; const YOUR_IMAGE_UPLOAD_SERVER_URL = 'https://www.aisada.ir/hamed/upload.php'; const CONVERTER_API_URL = 'https://texttopdf-5irq.onrender.com/'; const HF_IMAGE_GEN_URL_BASE = "https://black-forest-labs-flux-1-schnell.hf.space"; const FN_INDEX_GEN_IMAGE = 2; const TRIGGER_ID_GEN_IMAGE = 5; const STORAGE_INDEX_URL = '/storage/index'; const STORAGE_SESSION_URL = '/storage/session'; function getDimensionsFromRatio(ratioString) { const d = { width: 768, height: 1344 }; if (!ratioString || typeof ratioString !== 'string') return d; const r = ratioString.toLowerCase().replace(/[\sدر:بهx]/g, ''); if (r === '11') return { width: 1024, height: 1024 }; if (r === '169') return { width: 1344, height: 768 }; if (r === '916') return { width: 768, height: 1344 }; if (r === '43') return { width: 1152, height: 864 }; if (r === '34') return { width: 864, height: 1152 }; if (r === '32') return { width: 1216, height: 832 }; if (r === '23') return { width: 832, height: 1216 }; return d; } export async function convertTextToFile(content, format, buttonElement) { if (buttonElement) ui.showLoadingOnButton(buttonElement, true); try { const form1 = new FormData(); form1.append('content', content); form1.append('format', format); const r1 = await fetch(CONVERTER_API_URL, { method: 'POST', body: form1 }); if (!r1.ok) throw new Error(`HTTP ${r1.status}`); const blob = await r1.blob(); const fileName = `alpha-export-${Date.now()}.${format}`; const form2 = new FormData(); form2.append('image', blob, fileName); const r2 = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: form2 }); if (!r2.ok) throw new Error(await r2.text().catch(()=>`HTTP ${r2.status}`)); const j = await r2.json(); if (j.success && j.url) window.parent.postMessage({ type: 'OPEN_EXTERNAL_URL', url: j.url }, '*'); else throw new Error(j.message || 'upload failed'); } catch (e) { alert(`خطا: ${e.message}`); } finally { if (buttonElement) ui.showLoadingOnButton(buttonElement, false); } } function summarizeHistoryForApi(history) { return history.map(msg => { if (msg.isTemporary) return null; const newMsg = { ...msg }; const newParts = []; let has = false; if (newMsg.parts && newMsg.parts.length > 0) { for (const part of newMsg.parts) { const p = { ...part }; delete p.base64Data; delete p.inlineData; if (p.fileUrl && p.mimeType && !p.mimeType.startsWith('image/')) { let t = "این فایل"; if (p.mimeType.startsWith('video/')) t = "این ویدیو"; else if (p.mimeType.startsWith('audio/')) t = "این فایل صوتی"; else t = `این سند (${p.name})`; let tp = newParts.find(x => x.text); const note = `\n(دستور: ${t} را تحلیل کن و محتوای آن را شرح بده.)`; if (tp) tp.text += note; else newParts.push({ text: note }); } if (p.text || p.fileUrl || p.image_url || p.edited_images) { newParts.push(p); has = true; } } } if (msg.role === 'user' && msg.english_prompt_used) { const pn = `پرامپت انگلیسی استفاده شده: "${msg.english_prompt_used}"`; const rn = msg.aspect_ratio_used ? `, نسبت تصویر: "${msg.aspect_ratio_used}"` : ""; const mem = `\n[یادآوری برای مدل: این پیام کاربر منجر به ساخت تصویر شد. ${pn}${rn}]`; let tp = newParts.find(p => p.text); if (tp) tp.text += mem; else newParts.push({ text: mem }); has = true; } newMsg.parts = newParts; return has ? newMsg : null; }).filter(Boolean); } function uploadFileToHuggingFace(file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); state.setCurrentUploadXHR(xhr); xhr.open('POST', UPLOADER_API_URL, true); xhr.onload = function () { state.setCurrentUploadXHR(null); try { const res = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300) { if (res.hf_url) resolve(res); else reject(new Error("invalid response")); } else reject(new Error(res.error || `HTTP ${xhr.status}`)); } catch { reject(new Error("bad response")); } }; xhr.onerror = function () { state.setCurrentUploadXHR(null); if (xhr.status === 0 && !xhr.statusText) reject(new Error('آپلود لغو شد.')); else reject(new Error('خطای شبکه.')); }; xhr.upload.onprogress = function (e) { if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100)); }; const fd = new FormData(); fd.append('file', file); xhr.send(fd); }); } async function uploadUrlToHuggingFace(url) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', UPLOADER_API_URL, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function () { try { const res = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300 && res.hf_url) resolve(res); else reject(new Error(res.error || `HTTP ${xhr.status}`)); } catch { reject(new Error("bad response")); } }; xhr.onerror = function () { reject(new Error('خطای شبکه.')); }; xhr.send(JSON.stringify({ url })); }); } export async function processAndUploadFile(file, onProgress) { const readBase64 = f => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result.split(',')[1]); r.onerror = e => reject(e); r.readAsDataURL(f); }); const [up, b64] = await Promise.all([uploadFileToHuggingFace(file, onProgress), readBase64(file)]); return { url: up.hf_url, name: file.name, mimeType: file.type, base64Data: b64 }; } export async function uploadToAISADAAndOpenAlpha(imageUrlFromSpace, button) { if (!imageUrlFromSpace) { if (button) button.textContent = "خطا"; return; } if (button) { button.disabled = true; button.innerHTML = `<div class="typing-indicator" style="height:16px; gap: 3px;"><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span></div>`; } try { const r = await fetch(imageUrlFromSpace); if (!r.ok) throw new Error(`HTTP ${r.status}`); let blob = await r.blob(); const fd = new FormData(); let filename = `generated_alpha_image_${Date.now()}.webp`; try { const p = new URL(imageUrlFromSpace).pathname.split('/').pop(); if (p && p.includes('.')) filename = p.split('?')[0]; } catch {} fd.append('image', blob, filename); const up = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: fd }); if (!up.ok) throw new Error(await up.text().catch(()=>`HTTP ${up.status}`)); const j = await up.json(); if (j.success && j.url) window.parent.postMessage({ type: 'OPEN_EXTERNAL_URL', url: j.url }, '*'); else throw new Error(j.message || 'upload failed'); } catch (e) { if (button) button.textContent = "خطا"; alert(`خطا: ${e.message}`); } finally { if (button) { setTimeout(() => { button.disabled = false; button.innerHTML = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>`; }, 2000); } } } function convertImageBlobToPng(imageBlob) { return new Promise((resolve, reject) => { const u = URL.createObjectURL(imageBlob); const img = new Image(); img.onload = () => { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); c.toBlob(b => { resolve(b); URL.revokeObjectURL(u); }, 'image/png'); }; img.onerror = () => { reject(new Error('خطا در تبدیل تصویر.')); URL.revokeObjectURL(u); }; img.src = u; }); } export async function runExternalImageEditor(prompt, fileUrl, el, messageIndex) { const activeChat = state.getActiveChat(); const RENDER_API_URL = 'https://alfa-editor-worker.onrender.com/api/edit'; try { if (!fileUrl) throw new Error("فایل یافت نشد."); ui.showStatusUpdate("در حال دانلود فایل...", el); const r = await fetch(fileUrl); if (!r.ok) throw new Error(`HTTP ${r.status}`); let blob = await r.blob(); const mt = blob.type || r.headers.get('content-type'); if (mt && mt !== 'image/png' && mt !== 'image/jpeg') blob = await convertImageBlobToPng(blob); ui.showStatusUpdate("در حال ارسال برای ویرایش...", el); const fd = new FormData(); fd.append("image", blob, 'image.png'); fd.append("prompt", prompt); const resp = await fetch(RENDER_API_URL, { method: 'POST', body: fd, signal: state.globalAbortController.signal }); const result = await resp.json(); if (!resp.ok) throw new Error(result.error || `HTTP ${resp.status}`); if (!result.image_urls || result.image_urls.length === 0) throw new Error("تصویری برنگشت."); ui.showStatusUpdate("در حال دائمیسازی لینکها...", el); const urls = await Promise.all(result.image_urls.map(u => uploadUrlToHuggingFace(u).then(x => x.hf_url))); const finalMessage = { role: 'model', parts: [{ edited_images: urls }] }; if (typeof messageIndex !== 'undefined') { activeChat.messages[messageIndex] = finalMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(finalMessage, messageIndex, { isLastModel: true, animate: false }, el); } } catch (e) { if (e.name !== 'AbortError') ui.displayError(el, e.message || String(e)); else el.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات ویرایش متوقف شد --</p>'; } finally { ui.resetState(); } } export async function runExternalImageGenerator(englishPrompt, aspectRatio, el, followUpText = null) { const activeChat = state.getActiveChat(); const sessionHash = Math.random().toString(36).substring(2, 15); try { const dim = getDimensionsFromRatio(aspectRatio); ui.showStatusUpdate("ارسال درخواست تصویر...", el); const payload = [ englishPrompt, Math.floor(Math.random() * 2147483647), true, dim.width, dim.height, 4 ]; const joinPayload = { fn_index: FN_INDEX_GEN_IMAGE, data: payload, event_data: null, session_hash: sessionHash, trigger_id: TRIGGER_ID_GEN_IMAGE }; const joinResponse = await fetch(`${HF_IMAGE_GEN_URL_BASE}/queue/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(joinPayload), signal: state.globalAbortController.signal }); if (!joinResponse.ok) { let t = `HTTP ${joinResponse.status}`; try { const j = await joinResponse.json(); t = j.error || j.detail || t; } catch {} throw new Error(t); } const jr = await joinResponse.json(); if (!jr.event_id) throw new Error("event_id دریافت نشد."); if (state.currentImageEventSource) state.currentImageEventSource.close(); const es = new EventSource(`${HF_IMAGE_GEN_URL_BASE}/queue/data?session_hash=${sessionHash}`); state.setCurrentImageEventSource(es); await new Promise((resolve, reject) => { es.onmessage = async (event) => { const data = JSON.parse(event.data); if (data.msg === "process_starts") ui.showStatusUpdate("پردازش تصویر آغاز شد...", el); if (data.msg === "progress") { if (data.progress_data && data.progress_data.length > 0) { const p = data.progress_data[0]; ui.showStatusUpdate(`در حال ساخت... (${p.index}/${p.length})`, el); } } if (data.msg === "process_completed") { es.close(); state.setCurrentImageEventSource(null); if (data.success && data.output?.data?.[0]?.url) { const tempUrl = data.output.data[0].url; ui.showStatusUpdate("در حال دائمیسازی لینک...", el); try { const up = await uploadUrlToHuggingFace(tempUrl); const permanentUrl = up.hf_url; const newParts = [{ image_url: permanentUrl }]; if (followUpText) newParts.push({ text: followUpText }); const newModelMessage = { role: 'model', parts: newParts }; activeChat.messages[activeChat.messages.length - 1] = newModelMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(newModelMessage, activeChat.messages.length - 1, { isLastModel: true, animate: false }, el); resolve(); } catch (e) { reject(new Error(`خطای آپلود: ${e.message}`)); } } else reject(new Error(data.output?.error || "URL نامعتبر.")); } if (data.msg === 'queue_full') { es.close(); reject(new Error("صف پر است.")); } }; es.onerror = () => { es.close(); state.setCurrentImageEventSource(null); reject(new Error("قطع ارتباط با سرور تصویر.")); }; }); } catch (e) { if (e.name !== 'AbortError') ui.displayError(el, e.message); else el.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- تولید پاسخ متوقف شد --</p>'; } finally { ui.resetState(); } } export async function streamResponse(modelBubbleOuterDivElement, incomingHistory, chatId, actionPayload = null) { state.setGlobalAbortController(new AbortController()); let fullResponseText = ""; const activeChat = state.getActiveChat(); let finalMessageObject = { role: 'model', parts: [] }; let bodyPayload; try { const historyForApi = summarizeHistoryForApi(actionPayload ? incomingHistory.slice(0, -1) : incomingHistory); const inlineFiles = []; for (const msg of incomingHistory) { if (!msg || !msg.parts) continue; for (const p of msg.parts) { if (p.base64Data && p.mimeType) inlineFiles.push({ fileUrl: p.fileUrl || null, mimeType: p.mimeType, data: p.base64Data }); } } bodyPayload = { history: historyForApi, model: state.getActiveChat().model, chatId: chatId, action: actionPayload, inlineFiles }; } catch(e) { ui.displayError(modelBubbleOuterDivElement, `خطا در آمادهسازی: ${e.message}`); ui.resetState(); return; } try { const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: state.globalAbortController.signal, body: JSON.stringify(bodyPayload) }); if (!response.ok) { const err = await response.json().catch(()=>({error:{message:`HTTP ${response.status}`}})); throw new Error(err.error?.message || `HTTP ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; async function processEvent(rawEvent) { const lines = rawEvent.split('\n'); const dataPayload = lines.filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).filter(Boolean).join('\n'); if (!dataPayload) return; const data = JSON.parse(dataPayload); const lastUserMessage = activeChat.messages[activeChat.messages.length - 2]; if (data.intent === 'generate_image') { reader.cancel(); if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = data.english_prompt; lastUserMessage.aspect_ratio_used = data.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } await runExternalImageGenerator(data.english_prompt, data.aspect_ratio, modelBubbleOuterDivElement); return 'break'; } if (data.intent === 'generate_image_with_text') { reader.cancel(); const { text, image_generation_payload, follow_up_text } = data; if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = image_generation_payload.english_prompt; lastUserMessage.aspect_ratio_used = image_generation_payload.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } ui.streamFinalText(text, modelBubbleOuterDivElement); await runExternalImageGenerator(image_generation_payload.english_prompt, image_generation_payload.aspect_ratio, modelBubbleOuterDivElement, follow_up_text); return 'break'; } if (data.intent === 'clarify_action') { reader.cancel(); const commandMessage = { role: 'model', clarification: data.options, question: data.question, parts: [] }; activeChat.messages[activeChat.messages.length - 1] = commandMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(commandMessage, activeChat.messages.length - 1, { isLastModel: true }, modelBubbleOuterDivElement); ui.resetState(); return 'break'; } if (data.intent === 'edit_image') { reader.cancel(); let fileUrlForEditing = null; for (let i = incomingHistory.length - 2; i >= 0; i--) { const msg = incomingHistory[i]; if (!msg?.parts) continue; const p1 = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if (p1) { fileUrlForEditing = p1.image_url || p1.edited_images[0]; break; } const p2 = msg.parts.find(p => p.fileUrl); if (p2) { fileUrlForEditing = p2.fileUrl; break; } } if (!fileUrlForEditing) { ui.displayError(modelBubbleOuterDivElement, "فایلی برای ویرایش پیدا نشد."); ui.resetState(); return 'break'; } await runExternalImageEditor(data.prompt, fileUrlForEditing, modelBubbleOuterDivElement, activeChat.messages.length - 1); return 'break'; } if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); if (data.candidates) { const c = data.candidates[0] || {}; const parts = (c.content && c.content.parts) || []; const textPart = parts.find(p => typeof p.text === 'string'); if (textPart && textPart.text) { fullResponseText += textPart.text; ui.streamFinalText(fullResponseText, modelBubbleOuterDivElement, false); } } } while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sepIndex; while ((sepIndex = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, sepIndex).trim(); buffer = buffer.slice(sepIndex + 2); if (!rawEvent) continue; const res = await processEvent(rawEvent); if (res === 'break') return; } } if (buffer.trim().length) { const res = await processEvent(buffer.trim()); if (res === 'break') return; } if (fullResponseText) finalMessageObject.parts.push({ text: fullResponseText }); if (finalMessageObject.parts.length > 0) activeChat.messages[activeChat.messages.length - 1] = finalMessageObject; else activeChat.messages.pop(); state.saveSessions(); await saveSession(activeChat); ui.updateMessageActions(modelBubbleOuterDivElement, finalMessageObject, false, true); } catch (e) { if (e.name === 'AbortError') { modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات متوقف شد --</p>'; } else { ui.displayError(modelBubbleOuterDivElement, e.message || 'خطا'); } } finally { ui.resetState(); } } export async function loadAllSessionsFromHF() { const r = await fetch(STORAGE_INDEX_URL, { method: 'GET' }); if (!r.ok) return []; const idx = await r.json(); const ids = (idx.items || []).sort((a,b)=>new Date(b.updated_at)-new Date(a.updated_at)).map(x=>x.id); const sessions = []; for (const id of ids) { const sRes = await fetch(`${STORAGE_SESSION_URL}/${encodeURIComponent(id)}`, { method: 'GET' }); if (sRes.ok) sessions.push(await sRes.json()); } return sessions; } export async function saveSession(session) { await fetch(STORAGE_SESSION_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(session) }); } export async function deleteSession(id) { await fetch(`${STORAGE_SESSION_URL}/${encodeURIComponent(id)}`, { method: 'DELETE' }); }
javascript// main.js import * as state from './state.js'; import * as ui from './ui.js'; import * as api from './api.js'; async function handleFileSelection(event) { const file = event.target.files[0]; if (!file) return; ui.showFileUploading(file.name); ui.dom.submitButton.disabled = true; try { const onProgress = p => ui.updateUploadProgress(p); const uploaded = await api.processAndUploadFile(file, onProgress); state.setAttachedFile(uploaded); ui.showFileReady(file.name, uploaded.mimeType, uploaded.url); } catch (e) { if (e.message !== 'آپلود توسط کاربر لغو شد.') ui.showFileError(e.message); else ui.hideFilePreview(); } finally { event.target.value = ''; ui.toggleFilePopupMenu(false); ui.dom.submitButton.disabled = false; } } async function persistActive() { const s = state.getActiveChat(); if (s) await api.saveSession(s); } async function handleNewChat() { const newSession = { id: Date.now().toString(), title: 'چت جدید', model: 'gemini-1.5-pro-latest', messages: [] }; state.chatSessions.unshift(newSession); state.setActiveChatId(newSession.id); state.saveSessions(); await api.saveSession(newSession); ui.renderActiveChat(); ui.renderHistoryList(); } function getFullChatText(session) { if (!session || !session.messages) return ""; return session.messages.map(msg => { const prefix = msg.role === 'user' ? 'کاربر' : 'مدل'; const t = msg.parts?.find(p => p.text)?.text || '[محتوای غیر متنی]'; return `${prefix}:\n${t}`; }).join('\n\n---\n\n'); } document.addEventListener('DOMContentLoaded', async () => { ui.initTheme(); state.loadSessions(); try { const remote = await api.loadAllSessionsFromHF(); if (remote.length > 0) { state.setChatSessions(remote); state.setActiveChatId(remote[0].id); state.saveSessions(); } } catch {} if (state.chatSessions.length === 0 || !state.getActiveChat()) { await handleNewChat(); } else { state.setActiveChatId(state.activeChatId || state.chatSessions[0].id); ui.renderActiveChat(); ui.renderHistoryList(); } ui.setupMobileKeyboardFix(); ui.setMinTextareaHeight(ui.dom.messageInput.scrollHeight); ui.dom.messageForm.style.borderRadius = '28px'; ui.dom.newChatButton.addEventListener('click', handleNewChat); ui.dom.menuButton.addEventListener('click', () => ui.toggleSidebar(true)); ui.dom.sidebarOverlay.addEventListener('click', () => ui.toggleSidebar(false)); ui.dom.deleteAllChatsButton.addEventListener('click', async () => { ui.showConfirmModal('آیا از حذف تمام چتها مطمئن هستید؟', async () => { const ids = state.chatSessions.map(s=>s.id); state.setChatSessions([]); state.setActiveChatId(null); state.saveSessions(); for (const id of ids) await api.deleteSession(id); await handleNewChat(); ui.toggleSidebar(false); }); }); ui.dom.modelSelectButton.addEventListener('click', () => ui.toggleModal(true)); ui.dom.modelSelectModal.addEventListener('click', (e) => { if (e.target === ui.dom.modelSelectModal) ui.toggleModal(false); }); ui.dom.modelOptionCards.forEach(card => { card.addEventListener('click', async () => { const s = state.getActiveChat(); if (!s) return; s.model = card.dataset.model; ui.dom.currentModelName.textContent = card.dataset.name; ui.updateRadioButtons(); state.saveSessions(); await persistActive(); ui.toggleModal(false); }); }); ui.dom.settingsButton.addEventListener('click', () => ui.toggleSettingsModal(true)); ui.dom.settingsModal.addEventListener('click', (e) => { if (e.target === ui.dom.settingsModal) ui.toggleSettingsModal(false); }); ui.dom.themeToggle.addEventListener('change', (e) => { const t = e.target.checked ? 'dark' : 'light'; localStorage.setItem('theme', t); ui.applyTheme(t); }); ui.dom.attachFileButton.addEventListener('click', (e) => { e.stopPropagation(); const v = !ui.dom.filePopupMenu.classList.contains('hidden'); ui.toggleFilePopupMenu(!v); }); window.addEventListener('click', (e) => { if (!ui.dom.filePopupMenu.classList.contains('hidden') && !ui.dom.filePopupMenu.contains(e.target) && e.target !== ui.dom.attachFileButton && !ui.dom.attachFileButton.contains(e.target)) ui.toggleFilePopupMenu(false); }); ui.dom.selectImageOption.addEventListener('click', () => { ui.dom.imageFileInput.click(); }); ui.dom.selectFileOption.addEventListener('click', () => { ui.dom.generalFileInput.click(); }); ui.dom.imageFileInput.addEventListener('change', handleFileSelection); ui.dom.generalFileInput.addEventListener('change', handleFileSelection); ui.dom.removeImageButton.addEventListener('click', () => { if (state.currentUploadXHR) state.currentUploadXHR.abort(); state.setAttachedFile(null); ui.hideFilePreview(); ui.dom.submitButton.disabled = false; }); ui.dom.htmlPreviewCloseBtn.addEventListener('click', () => ui.toggleHtmlPreviewModal(false)); ui.dom.htmlPreviewOverlay.addEventListener('click', () => ui.toggleHtmlPreviewModal(false)); ui.dom.galleryDownloadBtn.addEventListener('click', function() { const url = ui.getCurrentGalleryImageUrl(); if (url) api.uploadToAISADAAndOpenAlpha(url, this); }); ui.dom.messageForm.addEventListener('submit', async (e) => { e.preventDefault(); if (state.isGenerating) { if (state.globalAbortController) state.globalAbortController.abort(); if (state.currentImageEventSource) state.currentImageEventSource.close(); return; } const s = state.getActiveChat(); if (!s) return; const txt = ui.dom.messageInput.value.trim(); if (!txt && !state.attachedFile) return; ui.setGeneratingState(true); const first = s.messages.length === 0; if (first) { const w = ui.dom.chatWindow.querySelector('.welcome-screen'); if (w) w.remove(); } const prevUserIdx = state.findLastIndex(s.messages, m => m.role === 'user'); if (prevUserIdx !== -1) { const el = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${prevUserIdx}"]`); if (el) ui.updateMessageActions(el, s.messages[prevUserIdx], false, false); } const prevModelIdx = state.findLastIndex(s.messages, m => m.role === 'model'); if (prevModelIdx !== -1) { const last = prevModelIdx === s.messages.length - 1; if (last) { const el = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${prevModelIdx}"]`); if (el) ui.updateMessageActions(el, s.messages[prevModelIdx], false, false); } } const parts = []; if (state.attachedFile) { parts.push({ fileUrl: state.attachedFile.url, mimeType: state.attachedFile.mimeType, name: state.attachedFile.name, base64Data: state.attachedFile.base64Data }); ui.hideFilePreview(); } if (txt) parts.push({ text: txt }); const userMsg = { role: 'user', parts }; s.messages.push(userMsg); ui.addMessageToUI(userMsg, s.messages.length - 1, {isLastUser: true, animate: true}); const modelPlaceholder = { role: 'model', isTemporary: true, parts: [] }; s.messages.push(modelPlaceholder); const modelBubble = ui.addMessageToUI(modelPlaceholder, s.messages.length - 1, {animate: true}); if (first && txt) { s.title = txt.substring(0, 30) + (txt.length > 30 ? '...' : ''); ui.renderHistoryList(); } ui.dom.messageInput.value = ''; ui.dom.messageInput.dispatchEvent(new Event('input')); state.saveSessions(); await persistActive(); try { await api.streamResponse(modelBubble, s.messages, s.id); } finally { if (state.attachedFile) state.setAttachedFile(null); } }); ui.dom.chatWindow.addEventListener('click', async (e) => { const galleryItem = e.target.closest('.js-image-gallery-grid [data-index]'); if (galleryItem) { const grid = galleryItem.closest('.js-image-gallery-grid'); const urls = grid.dataset.urls; const index = galleryItem.dataset.index; if (urls && index !== undefined) { ui.openImageGallery(urls, parseInt(index, 10)); return; } } const clarificationButton = e.target.closest('.clarification-button'); if (clarificationButton) { const payloadString = clarificationButton.dataset.actionPayload; if (!payloadString || state.isGenerating) return; const actionPayload = JSON.parse(payloadString); const s = state.getActiveChat(); if (!s) return; ui.setGeneratingState(true); const clarificationMessageElement = clarificationButton.closest('.message-entry'); const clarificationMessageIndex = parseInt(clarificationMessageElement.dataset.index, 10); if (isNaN(clarificationMessageIndex)) { ui.resetState(); return; } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; s.messages[clarificationMessageIndex] = modelPlaceholderMessage; const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, clarificationMessageIndex, {}, clarificationMessageElement); const historyBeforeAction = s.messages.slice(0, clarificationMessageIndex); if (actionPayload.intent === 'edit_image') { let fileUrlForEditing = null; for (let i = historyBeforeAction.length - 1; i >= 0; i--) { const msg = historyBeforeAction[i]; if (msg.parts) { const p1 = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if (p1) { fileUrlForEditing = p1.image_url || p1.edited_images[0]; break; } const p2 = msg.parts.find(p => p.fileUrl); if (p2) { fileUrlForEditing = p2.fileUrl; break; } } } if (fileUrlForEditing) await api.runExternalImageEditor(actionPayload.prompt, fileUrlForEditing, newModelBubble, clarificationMessageIndex); else { ui.displayError(newModelBubble, "تصویری برای ویرایش پیدا نشد."); ui.resetState(); } } else if (actionPayload.intent === 'regenerate_with_enhancement') { await api.streamResponse(newModelBubble, historyBeforeAction, s.id, actionPayload); } return; } const button = e.target.closest('.action-button'); if (!button) return; const action = button.dataset.action; const messageEntry = button.closest('.message-entry'); if (!messageEntry) return; const messageIndex = parseInt(messageEntry.dataset.index, 10); const s = state.getActiveChat(); if (!s || isNaN(messageIndex)) return; const message = s.messages[messageIndex]; if (action === 'download-image') { const url = message.parts.find(p => p.image_url)?.image_url; if (url) api.uploadToAISADAAndOpenAlpha(url, button); } else if (action === 'copy') { const textToCopy = message.parts?.find(p => p.text)?.text || ''; if (textToCopy) navigator.clipboard.writeText(textToCopy).then(() => { ui.showCopyFeedback(button); }); } else if (action === 'like' || action === 'dislike') { ui.handleLikeDislike(button, messageEntry); } else if (action === 'regenerate') { if (state.isGenerating) return; ui.setGeneratingState(true); state.setGpuGuideState(false); const lastModelMessageIndex = state.findLastIndex(s.messages, msg => msg.role === 'model'); if (messageIndex === lastModelMessageIndex) { s.messages.length = messageIndex; messageEntry.remove(); const lastUserMessageIndex = state.findLastIndex(s.messages, msg => msg.role === 'user'); if (lastUserMessageIndex !== -1) { const el = ui.dom.chatWindow.querySelector(`.message-entry[data-index="${lastUserMessageIndex}"]`); if (el) ui.updateMessageActions(el, s.messages[lastUserMessageIndex], true, false); } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; s.messages.push(modelPlaceholderMessage); const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, s.messages.length - 1, { animate: true }); state.saveSessions(); await persistActive(); await api.streamResponse(newModelBubble, s.messages, s.id); } else ui.resetState(); } else if (action === 'edit') { if (state.isGenerating) return; const lastUserMessageIndex = state.findLastIndex(s.messages, msg => msg.role === 'user'); if (messageIndex === lastUserMessageIndex) { const textPart = message.parts.find(p => p.text); const filePart = message.parts.find(p => p.fileUrl); if (textPart || filePart) { ui.showEditModal(textPart ? textPart.text : '', async (newText) => { ui.setGeneratingState(true); const all = ui.dom.chatWindow.querySelectorAll('.message-entry'); all.forEach(el => { const idx = parseInt(el.dataset.index, 10); if (idx >= messageIndex) el.remove(); }); s.messages.length = messageIndex; const newParts = []; if (filePart) newParts.push(filePart); if (newText.trim()) newParts.push({ text: newText }); if (newParts.length > 0) { const editedUserMessage = { role: 'user', parts: newParts }; s.messages.push(editedUserMessage); ui.addMessageToUI(editedUserMessage, s.messages.length - 1, { isLastUser: true, animate: true }); } const modelPlaceholderMessage = { role: 'model', isTemporary: true, parts: [] }; s.messages.push(modelPlaceholderMessage); state.saveSessions(); await persistActive(); const newModelBubble = ui.addMessageToUI(modelPlaceholderMessage, s.messages.length - 1, { animate: true }); await api.streamResponse(newModelBubble, s.messages, s.id); }); } } } else if (action === 'show-message-menu') { ui.showMessageMenu(e, messageIndex); } }); ui.dom.historyItemMenu.addEventListener('click', async (e) => { const btn = e.target.closest('.menu-item'); if (!btn) return; const action = btn.dataset.action; const format = btn.dataset.format; const sessionId = ui.dom.historyItemMenu.dataset.sessionId; const s = state.chatSessions.find(x => x.id === sessionId); if (!s) return; if (action === 'rename') { ui.showRenameModal(s.title, async (t) => { s.title = t; state.saveSessions(); await api.saveSession(s); ui.renderHistoryList(); }); } else if (action === 'delete') { ui.showConfirmModal(`آیا از حذف گفتگوی "${s.title}" مطمئن هستید؟`, async () => { state.setChatSessions(state.chatSessions.filter(x => x.id !== sessionId)); state.saveSessions(); await api.deleteSession(sessionId); if (state.activeChatId === sessionId) { if (state.chatSessions.length > 0) { state.setActiveChatId(state.chatSessions[0].id); ui.renderActiveChat(); } else { await handleNewChat(); } } ui.renderHistoryList(); }); } else if (action === 'convert-chat') { const text = getFullChatText(s); await api.convertTextToFile(text, format, btn); } ui.dom.historyItemMenu.classList.remove('visible'); }); ui.dom.messageItemMenu.addEventListener('click', async (e) => { const menu = ui.dom.messageItemMenu; const closeMenu = () => { menu.classList.remove('visible'); setTimeout(() => { menu.classList.add('hidden'); }, 300); }; if (e.target === ui.dom.messageItemMenuOverlay) { closeMenu(); return; } const btn = e.target.closest('.menu-item'); if (!btn) return; const action = btn.dataset.action; const format = btn.dataset.format; const messageIndex = parseInt(menu.dataset.messageIndex, 10); const s = state.getActiveChat(); if (!s || isNaN(messageIndex)) { closeMenu(); return; } const message = s.messages[messageIndex]; if (action === 'delete-message') { ui.showConfirmModal('آیا از حذف این پیام مطمئن هستید؟', async () => { state.deleteMessage(s.id, messageIndex); state.saveSessions(); await api.saveSession(s); ui.renderActiveChat(); }); } else if (action === 'convert-message') { const text = message.parts?.find(p => p.text)?.text || ''; if (text) await api.convertTextToFile(text, format, btn); else alert('محتوای متنی ندارد.'); } closeMenu(); }); ui.dom.messageInput.addEventListener('input', () => { ui.adjustTextareaHeight(ui.dom.messageInput, ui.dom.messageForm); }); ui.dom.editInput.addEventListener('input', () => { ui.adjustTextareaHeight(ui.dom.editInput); }); }); window.handleSuggestionClick = ui.handleSuggestionClick; window.uploadToAISADAAndOpenAlpha = api.uploadToAISADAAndOpenAlpha;
python# app.py import os import re import json import logging from pathlib import Path from flask import Flask, render_template, request, Response, jsonify from filelock import FileLock import requests from huggingface_hub import HfApi, CommitOperationAdd, CommitOperationDelete, create_repo, hf_hub_download class PersianLogFormatter(logging.Formatter): LEVEL_MAP = {logging.DEBUG: "دیباگ", logging.INFO: "اطلاع", logging.WARNING: "هشدار", logging.ERROR: "خطا", logging.CRITICAL: "بحرانی"} def format(self, record): record.levelname = self.LEVEL_MAP.get(record.levelno, record.levelname) return super().format(record) def setup_logging(): fmt = '[%Y-%m-%d %H:%M:%S]' formatter = PersianLogFormatter('[%(asctime)s] [%(levelname)s]: %(message)s', datefmt=fmt.strip('[]')) root = logging.getLogger() if root.hasHandlers(): root.handlers.clear() h = logging.StreamHandler(); h.setFormatter(formatter) root.addHandler(h); root.setLevel(logging.INFO) setup_logging() ALL_GEMINI_API_KEYS_STR = os.getenv('ALL_GEMINI_API_KEYS', '') if not ALL_GEMINI_API_KEYS_STR: raise RuntimeError("ALL_GEMINI_API_KEYS not set") MASTER_API_KEYS = [k.strip() for k in ALL_GEMINI_API_KEYS_STR.split(',') if k.strip()] if not MASTER_API_KEYS: raise RuntimeError("No valid keys") SHM = Path('/dev/shm'); SHM.mkdir(exist_ok=True) COUNTER_FILE_PATH = str(SHM / 'gunicorn_key_counter.txt') lock = FileLock(COUNTER_FILE_PATH + ".lock") with lock: if not os.path.exists(COUNTER_FILE_PATH): with open(COUNTER_FILE_PATH, 'w') as f: f.write('0') CACHE_DIR = SHM / 'file_cache'; CACHE_DIR.mkdir(parents=True, exist_ok=True) META_DIR = SHM / 'chat_meta'; META_DIR.mkdir(parents=True, exist_ok=True) HF_LOCAL_CACHE = SHM / 'hf_ds_cache'; HF_LOCAL_CACHE.mkdir(parents=True, exist_ok=True) HF_TOKEN = os.getenv("HF_TOKEN", "") HF_REPO_ID = os.getenv("HF_DATASET_REPO", "Asrasahar/alpha-chat-history") api_hf = HfApi(token=HF_TOKEN if HF_TOKEN else None) if HF_TOKEN: try: create_repo(repo_id=HF_REPO_ID, repo_type="dataset", token=HF_TOKEN, exist_ok=True) except Exception: pass app = Flask(__name__) GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' BASE_RULES_FA = """ دستورالعملهای اجباری برای دستیار هوش مصنوعی آلفا: هویت: تو «چتبات آلفا» هستی که توسط هوش مصنوعی آلفا توسعه داده شدهای. تحلیل جامع چندرسانهای: هر نوع فایل را تحلیل و محتوا را منظم شرح بده. پاسخ را با تشخیص نوع فایل شروع کن. اگر کاربر فقط فایل فرستاد و صراحتاً ساخت/ویرایش تصویر نخواست، فقط تحلیل کن. اولویت ابزارها: handle_specific_edit > change_image_aspect_ratio > regenerate_with_enhancement > generate_image > perform_internet_search """ def get_and_increment_key_index(): l = FileLock(COUNTER_FILE_PATH + ".lock") with l: try: with open(COUNTER_FILE_PATH, 'r') as f: cur = int(f.read().strip()) except (FileNotFoundError, ValueError): cur = 0 idx = cur % len(MASTER_API_KEYS) with open(COUNTER_FILE_PATH, 'w') as f: f.write(str(cur + 1)) return idx def get_keys_for_request(): s = get_and_increment_key_index() return MASTER_API_KEYS[s:] + MASTER_API_KEYS[:s] def _meta_path(chat_id: str) -> Path: safe = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return META_DIR / f"{safe}.json" def load_chat_meta(chat_id: str) -> dict: p = _meta_path(chat_id); l = FileLock(str(p) + ".lock") with l: if p.exists(): try: return json.load(open(p, 'r', encoding='utf-8')) except Exception: return {} return {} def save_chat_meta(chat_id: str, meta: dict): p = _meta_path(chat_id); l = FileLock(str(p) + ".lock") with l: try: with open(p, 'w', encoding='utf-8') as f: json.dump(meta or {}, f, ensure_ascii=False) except Exception: pass def update_chat_meta(chat_id: str, **kwargs): m = load_chat_meta(chat_id) m.update({k: v for k, v in kwargs.items() if v is not None}) save_chat_meta(chat_id, m) def _file_cache_path(chat_id: str) -> Path: safe = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return CACHE_DIR / f"{safe}.json" def load_file_cache(chat_id: str) -> dict: p = _file_cache_path(chat_id) if p.exists(): try: return json.load(open(p, 'r', encoding='utf-8')) except Exception: pass return {"files": []} def save_file_cache(chat_id: str, cache: dict): p = _file_cache_path(chat_id); l = FileLock(str(p) + ".lock") with l: try: with open(p, 'w', encoding='utf-8') as f: json.dump(cache or {"files": []}, f, ensure_ascii=False) except Exception: pass def update_file_cache_from_inline_files(chat_id: str, inline_files: list): if not inline_files: return cache = load_file_cache(chat_id) existing = {f.get("fileUrl"): f for f in cache.get("files", []) if f.get("fileUrl")} changed = False for it in inline_files: url = (it or {}).get("fileUrl") mime = (it or {}).get("mimeType") data = (it or {}).get("data") if not (url and mime and data): continue rec = {"fileUrl": url, "inlineData": {"mimeType": mime, "data": data}} if url not in existing or existing[url].get("inlineData", {}).get("data") != data: existing[url] = rec; changed = True if changed: cache["files"] = list(existing.values()) save_file_cache(chat_id, cache) def inject_inline_data_into_history(chat_id: str, history: list) -> list: cache = load_file_cache(chat_id) mapping = {f.get("fileUrl"): f.get("inlineData") for f in cache.get("files", []) if f.get("fileUrl")} if not mapping: return history out = [] for msg in history: if msg.get("role") != "user": out.append(msg); continue parts = msg.get("parts", []) new_parts = [] for p in parts: if isinstance(p, dict) and p.get("fileUrl"): url = p["fileUrl"] if url in mapping: new_parts.append({"inlineData": {"mimeType": mapping[url]["mimeType"], "data": mapping[url]["data"]}}) new_parts.append(p) msg2 = {"role": msg["role"], "parts": new_parts} out.append(msg2) return out def get_all_tools(): search_tool = {"name":"perform_internet_search","description":"جستجوی بهروز","parameters":{"type":"OBJECT","properties":{"query":{"type":"STRING"}},"required":["query"]}} image_tools = [ {"name":"generate_image","description":"ساخت تصویر جدید","parameters":{"type":"OBJECT","properties":{"english_prompt":{"type":"STRING"},"aspect_ratio":{"type":"STRING"},"initial_response_text":{"type":"STRING"},"follow_up_text":{"type":"STRING"}},"required":["english_prompt","initial_response_text","follow_up_text"]}}, {"name":"handle_specific_edit","description":"ویرایش مشخص","parameters":{"type":"OBJECT","properties":{"edit_request":{"type":"STRING"}},"required":["edit_request"]}}, {"name":"regenerate_with_enhancement","description":"بازسازی با بهبود","parameters":{"type":"OBJECT","properties":{"enhancement_request":{"type":"STRING"},"previous_english_prompt":{"type":"STRING"},"previous_aspect_ratio":{"type":"STRING"}},"required":["enhancement_request","previous_english_prompt","previous_aspect_ratio"]}}, {"name":"change_image_aspect_ratio","description":"تغییر نسبت تصویر","parameters":{"type":"OBJECT","properties":{"new_aspect_ratio":{"type":"STRING"},"previous_english_prompt":{"type":"STRING"}},"required":["new_aspect_ratio","previous_english_prompt"]}} ] return [{"function_declarations": image_tools + [search_tool]}] def enhance_prompt(base_prompt, enhancement_request, model): api_key = get_keys_for_request()[0] url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent?key={api_key}" system_prompt = f"You are an expert prompt engineer. Merge the base English image prompt with the user's modification. Return only the final English prompt.\n\nBase Prompt: \"{base_prompt}\"\nUser's Request: \"{enhancement_request}\"" payload = {"contents":[{"role":"user","parts":[{"text":system_prompt}]}],"generationConfig":{"temperature":0.7,"maxOutputTokens":2048}} try: r = requests.post(url, json=payload, timeout=60); r.raise_for_status() return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip() except Exception: return f"{base_prompt}, {enhancement_request}" def create_artistic_prompt(user_prompt, model): api_key = get_keys_for_request()[0] url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent?key={api_key}" system_instruction = "Convert user's idea to a highly-detailed English prompt. Output ONLY the final English prompt." payload = {"contents":[{"role":"user","parts":[{"text":user_prompt}]}],"systemInstruction":{"parts":[{"text":system_instruction}]},"generationConfig":{"temperature":0.7,"maxOutputTokens":2048}} try: r = requests.post(url, json=payload, timeout=60); r.raise_for_status() return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip() except Exception: return user_prompt def stream_search_results(query): keys = get_keys_for_request() model = 'gemini-2.5-flash' url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse" payload = {"contents":[{"role":"user","parts":[{"text":query}]}],"tools":[{"google_search":{}}],"systemInstruction":{"parts":[{"text":"Answer in Persian."}]}} for k in keys: try: with requests.post(url, params={'key': k}, json=payload, stream=True, timeout=180) as r: if r.status_code == 429: continue r.raise_for_status() for line in r.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code': 'SEARCH_FAILED','message': 'سرویس جستجو موقتاً در دسترس نیست.'}})}\n\n" def is_tool_like_text(txt: str) -> bool: if not txt: return False patterns = [r'\bfunctionCall\s*:', r'\bhandle_specific_edit\s*\(', r'\bchange_image_aspect_ratio\s*\(', r'\bregenerate_with_enhancement\s*\(', r'\bgenerate_image\s*\(', r'print\s*\(\s*handle_specific_edit'] return any(re.search(p, txt, flags=re.I) for p in patterns) def sse_text_event(text: str) -> str: return f"data: {json.dumps({'candidates':[{'content':{'parts':[{'text': text}]}}]})}\n\n" def classify_user_intent(user_text: str) -> dict: if not user_text or not user_text.strip(): return {"intent":"NONE"} keys = get_keys_for_request() model = 'gemini-1.5-flash' url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent" system = "Return strict JSON: {\"intent\":\"...\", \"normalized_edit\":\"...\", \"new_aspect_ratio\":\"...\", \"code_language\":\"...\"}. intents=[NONE,SPECIFIC_EDIT,ASPECT_RATIO_CHANGE,QUALITY_ENHANCEMENT,NEW_IMAGE,CODE_TASK]." payload = {"contents":[{"role":"user","parts":[{"text": user_text}]}],"systemInstruction":{"parts":[{"text": system}]},"generationConfig":{"temperature":0.0,"maxOutputTokens":128}} for k in keys: try: r = requests.post(f"{url}?key={k}", json=payload, timeout=25) if r.status_code == 429: continue r.raise_for_status() txt = r.json()["candidates"][0]["content"]["parts"][0]["text"].strip().strip('`').strip() s, e = txt.find('{'), txt.rfind('}') if s != -1 and e != -1: txt = txt[s:e+1] d = json.loads(txt) if "intent" not in d: return {"intent":"NONE"} return d except Exception: continue return {"intent":"NONE"} def stream_text_only(user_text: str, model_name: str): keys = get_keys_for_request() system_text = BASE_RULES_FA + "\n[TURN MODE: TEXT ONLY]\n" contents = [{"role":"user","parts":[{"text": user_text }]}] for k in keys: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={k}" payload = {"contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}} with requests.post(url, json=payload, stream=True, timeout=720) as resp: if resp.status_code == 429: continue resp.raise_for_status() for line in resp.iter_lines(): if line and line.startswith(b"data: "): yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'TEXT_STREAM_FAILED','message':'پاسخ متنی موقتاً در دسترس نیست.'}})}\n\n" def stream_code_reply(user_text: str, model_name: str, code_language: str = None): keys = get_keys_for_request() system_text = BASE_RULES_FA + "\n[TURN MODE: CODE]\n" contents = [{"role":"user","parts":[{"text": user_text }]}] for k in keys: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={k}" payload = {"contents": contents, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.2, "maxOutputTokens": 4096}} with requests.post(url, json=payload, stream=True, timeout=720) as r: if r.status_code == 429: continue r.raise_for_status() for line in r.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'CODE_STREAM_FAILED','message':'تولید کد موقتاً در دسترس نیست.'}})}\n\n" def stream_file_analysis(history_for_gemini, model_name: str): keys = get_keys_for_request() system_text = BASE_RULES_FA + "\n[TURN MODE: FILE ANALYSIS]\n" for k in keys: try: url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse&key={k}" payload = {"contents": history_for_gemini, "tools": None, "systemInstruction": {"parts": [{"text": system_text}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}} with requests.post(url, json=payload, stream=True, timeout=720) as r: if r.status_code == 429: continue r.raise_for_status() for line in r.iter_lines(): if line: yield line.decode('utf-8') + "\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'FILE_ANALYSIS_FAILED','message':'تحلیل فایل موقتاً در دسترس نیست.'}})}\n\n" @app.route('/') def index(): return render_template('index.html') @app.route('/storage/index', methods=['GET']) def storage_index(): try: try: path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) data = json.load(open(path, 'r', encoding='utf-8')) except Exception: data = {"items": []} return jsonify(data) except Exception: return jsonify({"items": []}) @app.route('/storage/session/<chat_id>', methods=['GET', 'DELETE']) def storage_session_get_delete(chat_id): if request.method == 'GET': try: path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename=f"sessions/{chat_id}.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) data = json.load(open(path, 'r', encoding='utf-8')) return jsonify(data) except Exception: return jsonify({"error":"not_found"}), 404 else: try: try: idx_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) index_data = json.load(open(idx_path, 'r', encoding='utf-8')) except Exception: index_data = {"items": []} index_data["items"] = [it for it in index_data.get("items", []) if it.get("id") != chat_id] ops = [CommitOperationDelete(path_in_repo=f"sessions/{chat_id}.json"), CommitOperationAdd(path_in_repo="index.json", path_or_fileobj=json.dumps(index_data, ensure_ascii=False).encode('utf-8'))] if HF_TOKEN: api_hf.create_commit(repo_id=HF_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"delete session {chat_id}", token=HF_TOKEN) return jsonify({"ok": True}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route('/storage/session', methods=['POST']) def storage_session_upsert(): try: session = request.get_json(force=True) if not session or "id" not in session: return jsonify({"ok": False, "error": "invalid session"}), 400 chat_id = str(session["id"]) title = session.get("title","") from datetime import datetime, timezone updated_at = datetime.now(timezone.utc).isoformat() try: idx_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) index_data = json.load(open(idx_path, 'r', encoding='utf-8')) except Exception: index_data = {"items": []} items = [it for it in index_data.get("items", []) if it.get("id") != chat_id] items.append({"id": chat_id, "title": title or f"chat-{chat_id}", "updated_at": updated_at}) index_data["items"] = items ops = [ CommitOperationAdd(path_in_repo=f"sessions/{chat_id}.json", path_or_fileobj=json.dumps(session, ensure_ascii=False).encode('utf-8')), CommitOperationAdd(path_in_repo="index.json", path_or_fileobj=json.dumps(index_data, ensure_ascii=False).encode('utf-8')) ] if HF_TOKEN: api_hf.create_commit(repo_id=HF_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"upsert session {chat_id}", token=HF_TOKEN) return jsonify({"ok": True}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route('/chat', methods=['POST']) def chat(): data = request.json or {} model = data.get('model', 'gemini-2.5-flash') history = data.get('history', []) action_payload = data.get('action') chat_id = data.get('chatId') inline_files = data.get('inlineFiles', []) if not chat_id: return Response(json.dumps({"error": "chatId is required"}), status=400, mimetype='application/json') try: update_file_cache_from_inline_files(chat_id, inline_files) except Exception: pass if action_payload and action_payload.get('intent') == 'regenerate_with_enhancement': def stream_action_result(): try: base_prompt = action_payload.get("base_english_prompt") enhancement_request = action_payload.get("enhancement_request") aspect_ratio = action_payload.get("aspect_ratio", "9:16") if not base_prompt or not enhancement_request: yield f"data: {json.dumps({'error': {'code':'MISSING_ARGS','message':'پارامترهای لازم موجود نیست.'}})}\n\n"; return new_prompt = enhance_prompt(base_prompt, enhancement_request, model) yield f"data: {json.dumps({'intent':'generate_image','english_prompt': new_prompt,'aspect_ratio': aspect_ratio})}\n\n" except Exception as e: yield f"data: {json.dumps({'error': {'code':'ACTION_EXECUTION_FAILED','message': str(e)}})}\n\n" return Response(stream_action_result(), mimetype='text/event-stream') def stream_events(): def send_event(event_data): return f"data: {json.dumps(event_data)}\n\n" history_for_gemini = [] for msg in history: if 'role' not in msg: continue new_msg = {'role': msg['role'], 'parts': []} for part in (msg.get('parts') or []): if isinstance(part, dict) and any(k in part for k in ['text','inlineData','functionCall','functionResponse','image_url','edited_images','fileUrl','mimeType','name']): if 'image_url' in part or 'edited_images' in part: new_msg['parts'].append({'text': '(توضیح: تصویر در پیامهای قبلی ساخته یا ویرایش شده است.)'}) elif 'fileUrl' in part and 'mimeType' in part and not part.get('mimeType','').startswith('image/'): new_msg['parts'].append({'text': f"(یادداشت: فایلی با نوع {part.get('mimeType')} قبلاً ارسال شده است.)"}) elif 'text' in part or 'inlineData' in part: new_msg['parts'].append(part) if new_msg['parts']: history_for_gemini.append(new_msg) history_for_gemini = inject_inline_data_into_history(chat_id, history_for_gemini) last_user_idx_local = -1 for i in range(len(history_for_gemini) - 1, -1, -1): if history_for_gemini[i].get('role') == 'user': last_user_idx_local = i; break last_user_text = ""; has_inline_file = False if last_user_idx_local != -1: lu = history_for_gemini[last_user_idx_local] last_user_text = ''.join(p.get('text','') for p in (lu.get('parts') or []) if p.get('text')).strip() has_inline_file = any('inlineData' in p for p in (lu.get('parts') or [])) image_in_recent_history = any(m.get('role') == 'model' and any('image_url' in p or 'edited_images' in p for p in (m.get('parts') or [])) for m in history[-4:]) intent_info = classify_user_intent(last_user_text) if last_user_text else {"intent":"NONE"} intent = (intent_info.get("intent") or "NONE").upper() if intent == "CODE_TASK": yield from stream_code_reply(last_user_text, model, intent_info.get("code_language")); return if image_in_recent_history and last_user_text and intent == "NONE": yield from stream_text_only(last_user_text, model); return if has_inline_file and intent in {"NONE"}: yield from stream_file_analysis(history_for_gemini, model); return if image_in_recent_history and last_user_text: if intent == "SPECIFIC_EDIT": merged = (intent_info.get("normalized_edit") or last_user_text) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent": "edit_image", "prompt": merged}); return elif intent == "ASPECT_RATIO_CHANGE": meta = load_chat_meta(chat_id) ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = intent_info.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent": "generate_image", "english_prompt": ep, "aspect_ratio": new_ar}); return elif intent == "QUALITY_ENHANCEMENT": meta = load_chat_meta(chat_id) prev_ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = meta.get("last_aspect_ratio") or "9:16" enh = intent_info.get("normalized_edit") or last_user_text yield send_event({"intent": "clarify_action", "question": "بسیار خب! تصویر فعلی را ویرایش کنم یا یک تصویر جدید بسازم؟","options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enh},"regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enh,"aspect_ratio": prev_ar}}}); return elif intent == "NEW_IMAGE": ep = create_artistic_prompt(last_user_text, model) update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio="9:16", last_edit_prompt=None) yield send_event({"intent": "generate_image_with_text", "text": "در حال ساخت تصویر جدید برای شما...","image_generation_payload": {"english_prompt": ep, "aspect_ratio": "9:16"}, "follow_up_text": "تصویر شما آماده شد!"}); return tools_for_request = get_all_tools() final_system_prompt = BASE_RULES_FA + "\n[TURN MODE: GENERAL]\n" keys = get_keys_for_request() accumulated_args = None fn_name = None for k in keys: try: url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse&key={k}" payload = {"contents": history_for_gemini, "tools": tools_for_request, "systemInstruction": {"parts": [{"text": final_system_prompt}]}, "generationConfig":{"temperature":0.3,"maxOutputTokens":8192}} with requests.post(url, json=payload, stream=True, timeout=720) as r: if r.status_code == 429: continue r.raise_for_status() for line in r.iter_lines(): if line and line.startswith(b'data: '): chunk_str = line.decode('utf-8')[6:] try: data_chunk = json.loads(chunk_str) part = data_chunk.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0] if "functionCall" in part: if not accumulated_args: accumulated_args = {}; fn_name = part["functionCall"].get("name") args_chunk = part["functionCall"].get("args", {}) for kk, vv in (args_chunk or {}).items(): if kk not in accumulated_args: accumulated_args[kk] = vv elif isinstance(accumulated_args.get(kk), str): accumulated_args[kk] += str(vv) elif "text" in part: if not is_tool_like_text(part["text"]): yield sse_text_event(part["text"]) except Exception: continue if accumulated_args: args = accumulated_args if fn_name == "handle_specific_edit": raw_req = (args.get("edit_request") or "").strip() if not raw_req: yield send_event({"error": {"code":"EMPTY_EDIT_REQUEST","message":"دستور ویرایش دریافت نشد."}}) else: merged = raw_req update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent":"edit_image","prompt": merged}) elif fn_name == "regenerate_with_enhancement": meta = load_chat_meta(chat_id) prev_ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = args.get("previous_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" enhancement_request = args.get("enhancement_request", "") yield send_event({"intent":"clarify_action","question":"بسیار خب! با این تغییرات چه کنم؟","options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enhancement_request},"regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enhancement_request,"aspect_ratio": prev_ar}}}) elif fn_name == "change_image_aspect_ratio": meta = load_chat_meta(chat_id) ep = args.get("previous_english_prompt") or meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = args.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent":"generate_image","english_prompt": ep,"aspect_ratio": new_ar}) elif fn_name == "generate_image": ep = args.get("english_prompt"); ar = args.get("aspect_ratio", "9:16") update_chat_meta(chat_id, last_english_prompt=ep, last_aspect_ratio=ar, last_edit_prompt=None) yield send_event({"intent":"generate_image_with_text","text": args.get("initial_response_text"),"image_generation_payload":{"english_prompt": ep,"aspect_ratio": ar},"follow_up_text": args.get("follow_up_text")}) elif fn_name == "perform_internet_search": yield from stream_search_results(args.get('query')) return except requests.exceptions.RequestException: continue yield send_event({"error": {"code": "ALL_KEYS_FAILED", "message": "تمام کلیدهای API خطا دادند یا درخواست نامعتبر بود."}}) return Response(stream_events(), mimetype='text/event-stream') if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=os.environ.get("PORT", 7860))
باز هم پیام ها نصفه نیمه میاد و وسط راه قطع میشه خصوصاً اگر سابقه چت یکم بیشتر باشه پاسخ ها نصفه نیمه میاد و کامل نمیاد
javascript// api.js import * as state from './state.js'; import * as ui from './ui.js'; const UPLOADER_API_URL = 'https://asrasahar-ok-uploader-bot.hf.space/upload'; const YOUR_IMAGE_UPLOAD_SERVER_URL = 'https://www.aisada.ir/hamed/upload.php'; const CONVERTER_API_URL = 'https://texttopdf-5irq.onrender.com/'; const HF_IMAGE_GEN_URL_BASE = "https://black-forest-labs-flux-1-schnell.hf.space"; const FN_INDEX_GEN_IMAGE = 2; const TRIGGER_ID_GEN_IMAGE = 5; const STORAGE_INDEX_URL = '/storage/index'; const STORAGE_SESSION_URL = '/storage/session'; const MAX_AUTO_CONTINUES = 6; function getDimensionsFromRatio(ratioString) { const d = { width: 768, height: 1344 }; if (!ratioString || typeof ratioString !== 'string') return d; const r = ratioString.toLowerCase().replace(/[\sدر:بهx]/g, ''); if (r === '11') return { width: 1024, height: 1024 }; if (r === '169') return { width: 1344, height: 768 }; if (r === '916') return { width: 768, height: 1344 }; if (r === '43') return { width: 1152, height: 864 }; if (r === '34') return { width: 864, height: 1152 }; if (r === '32') return { width: 1216, height: 832 }; if (r === '23') return { width: 832, height: 1216 }; return d; } export async function convertTextToFile(content, format, buttonElement) { if (buttonElement) ui.showLoadingOnButton(buttonElement, true); try { const form1 = new FormData(); form1.append('content', content); form1.append('format', format); const r1 = await fetch(CONVERTER_API_URL, { method: 'POST', body: form1 }); if (!r1.ok) throw new Error(`HTTP ${r1.status}`); const blob = await r1.blob(); const fileName = `alpha-export-${Date.now()}.${format}`; const form2 = new FormData(); form2.append('image', blob, fileName); const r2 = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: form2 }); if (!r2.ok) throw new Error(await r2.text().catch(()=>`HTTP ${r2.status}`)); const j = await r2.json(); if (j.success && j.url) window.parent.postMessage({ type: 'OPEN_EXTERNAL_URL', url: j.url }, '*'); else throw new Error(j.message || 'upload failed'); } catch (e) { alert(`خطا: ${e.message}`); } finally { if (buttonElement) ui.showLoadingOnButton(buttonElement, false); } } function summarizeHistoryForApi(history) { return history.map(msg => { if (msg.isTemporary) return null; const newMsg = { ...msg }; const newParts = []; let has = false; if (newMsg.parts && newMsg.parts.length > 0) { for (const part of newMsg.parts) { const p = { ...part }; delete p.base64Data; delete p.inlineData; if (p.fileUrl && p.mimeType && !p.mimeType.startsWith('image/')) { let t = "این فایل"; if (p.mimeType.startsWith('video/')) t = "این ویدیو"; else if (p.mimeType.startsWith('audio/')) t = "این فایل صوتی"; else t = `این سند (${p.name})`; let tp = newParts.find(x => x.text); const note = `\n(دستور: ${t} را تحلیل کن و محتوای آن را شرح بده.)`; if (tp) tp.text += note; else newParts.push({ text: note }); } if (p.text || p.fileUrl || p.image_url || p.edited_images) { newParts.push(p); has = true; } } } if (msg.role === 'user' && msg.english_prompt_used) { const pn = `پرامپت انگلیسی استفاده شده: "${msg.english_prompt_used}"`; const rn = msg.aspect_ratio_used ? `, نسبت تصویر: "${msg.aspect_ratio_used}"` : ""; const mem = `\n[یادآوری برای مدل: این پیام کاربر منجر به ساخت تصویر شد. ${pn}${rn}]`; let tp = newParts.find(p => p.text); if (tp) tp.text += mem; else newParts.push({ text: mem }); has = true; } newMsg.parts = newParts; return has ? newMsg : null; }).filter(Boolean); } function uploadFileToHuggingFace(file, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); state.setCurrentUploadXHR(xhr); xhr.open('POST', UPLOADER_API_URL, true); xhr.onload = function () { state.setCurrentUploadXHR(null); try { const res = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300) { if (res.hf_url) resolve(res); else reject(new Error("invalid response")); } else reject(new Error(res.error || `HTTP ${xhr.status}`)); } catch { reject(new Error("bad response")); } }; xhr.onerror = function () { state.setCurrentUploadXHR(null); if (xhr.status === 0 && !xhr.statusText) reject(new Error('آپلود لغو شد.')); else reject(new Error('خطای شبکه.')); }; xhr.upload.onprogress = function (e) { if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100)); }; const fd = new FormData(); fd.append('file', file); xhr.send(fd); }); } async function uploadUrlToHuggingFace(url) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', UPLOADER_API_URL, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function () { try { const res = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300 && res.hf_url) resolve(res); else reject(new Error(res.error || `HTTP ${xhr.status}`)); } catch { reject(new Error("bad response")); } }; xhr.onerror = function () { reject(new Error('خطای شبکه.')); }; xhr.send(JSON.stringify({ url })); }); } export async function processAndUploadFile(file, onProgress) { const readBase64 = f => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result.split(',')[1]); r.onerror = e => reject(e); r.readAsDataURL(f); }); const [up, b64] = await Promise.all([uploadFileToHuggingFace(file, onProgress), readBase64(file)]); return { url: up.hf_url, name: file.name, mimeType: file.type, base64Data: b64 }; } export async function uploadToAISADAAndOpenAlpha(imageUrlFromSpace, button) { if (!imageUrlFromSpace) { if (button) button.textContent = "خطا"; return; } if (button) { button.disabled = true; button.innerHTML = `<div class="typing-indicator" style="height:16px; gap: 3px;"><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span><span style="width:6px; height:6px; background-color:currentColor;"></span></div>`; } try { const r = await fetch(imageUrlFromSpace); if (!r.ok) throw new Error(`HTTP ${r.status}`); const blob = await r.blob(); const fd = new FormData(); let filename = `generated_alpha_image_${Date.now()}.webp`; try { const p = new URL(imageUrlFromSpace).pathname.split('/').pop(); if (p && p.includes('.')) filename = p.split('?')[0]; } catch {} fd.append('image', blob, filename); const up = await fetch(YOUR_IMAGE_UPLOAD_SERVER_URL, { method: 'POST', body: fd }); if (!up.ok) throw new Error(await up.text().catch(()=>`HTTP ${up.status}`)); const j = await up.json(); if (j.success && j.url) window.parent.postMessage({ type: 'OPEN_EXTERNAL_URL', url: j.url }, '*'); else throw new Error(j.message || 'upload failed'); } catch (e) { if (button) button.textContent = "خطا"; alert(`خطا: ${e.message}`); } finally { if (button) { setTimeout(() => { button.disabled = false; button.innerHTML = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>`; }, 2000); } } } function convertImageBlobToPng(imageBlob) { return new Promise((resolve, reject) => { const u = URL.createObjectURL(imageBlob); const img = new Image(); img.onload = () => { const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); c.toBlob(b => { resolve(b); URL.revokeObjectURL(u); }, 'image/png'); }; img.onerror = () => { reject(new Error('خطا در تبدیل تصویر.')); URL.revokeObjectURL(u); }; img.src = u; }); } export async function runExternalImageEditor(prompt, fileUrl, el, messageIndex) { const activeChat = state.getActiveChat(); const RENDER_API_URL = 'https://alfa-editor-worker.onrender.com/api/edit'; try { if (!fileUrl) throw new Error("فایل یافت نشد."); ui.showStatusUpdate("در حال دانلود فایل...", el); const r = await fetch(fileUrl); if (!r.ok) throw new Error(`HTTP ${r.status}`); let blob = await r.blob(); const mt = blob.type || r.headers.get('content-type'); if (mt && mt !== 'image/png' && mt !== 'image/jpeg') blob = await convertImageBlobToPng(blob); ui.showStatusUpdate("در حال ارسال برای ویرایش...", el); const fd = new FormData(); fd.append("image", blob, 'image.png'); fd.append("prompt", prompt); const resp = await fetch(RENDER_API_URL, { method: 'POST', body: fd, signal: state.globalAbortController.signal }); const result = await resp.json(); if (!resp.ok) throw new Error(result.error || `HTTP ${resp.status}`); if (!result.image_urls || result.image_urls.length === 0) throw new Error("تصویری برنگشت."); ui.showStatusUpdate("در حال دائمیسازی لینکها...", el); const urls = await Promise.all(result.image_urls.map(u => uploadUrlToHuggingFace(u).then(x => x.hf_url))); const finalMessage = { role: 'model', parts: [{ edited_images: urls }] }; if (typeof messageIndex !== 'undefined') { activeChat.messages[messageIndex] = finalMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(finalMessage, messageIndex, { isLastModel: true, animate: false }, el); } } catch (e) { if (e.name !== 'AbortError') ui.displayError(el, e.message || String(e)); else el.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات ویرایش متوقف شد --</p>'; } finally { ui.resetState(); } } export async function runExternalImageGenerator(englishPrompt, aspectRatio, el, followUpText = null) { const activeChat = state.getActiveChat(); const sessionHash = Math.random().toString(36).substring(2, 15); try { const dim = getDimensionsFromRatio(aspectRatio); ui.showStatusUpdate("ارسال درخواست تصویر...", el); const payload = [ englishPrompt, Math.floor(Math.random() * 2147483647), true, dim.width, dim.height, 4 ]; const joinPayload = { fn_index: FN_INDEX_GEN_IMAGE, data: payload, event_data: null, session_hash: sessionHash, trigger_id: TRIGGER_ID_GEN_IMAGE }; const joinResponse = await fetch(`${HF_IMAGE_GEN_URL_BASE}/queue/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(joinPayload), signal: state.globalAbortController.signal }); if (!joinResponse.ok) { let t = `HTTP ${joinResponse.status}`; try { const j = await joinResponse.json(); t = j.error || j.detail || t; } catch {} throw new Error(t); } const jr = await joinResponse.json(); if (!jr.event_id) throw new Error("event_id دریافت نشد."); if (state.currentImageEventSource) state.currentImageEventSource.close(); const es = new EventSource(`${HF_IMAGE_GEN_URL_BASE}/queue/data?session_hash=${sessionHash}`); state.setCurrentImageEventSource(es); await new Promise((resolve, reject) => { es.onmessage = async (event) => { const data = JSON.parse(event.data); if (data.msg === "process_starts") ui.showStatusUpdate("پردازش تصویر آغاز شد...", el); if (data.msg === "progress") { if (data.progress_data && data.progress_data.length > 0) { const p = data.progress_data[0]; ui.showStatusUpdate(`در حال ساخت... (${p.index}/${p.length})`, el); } } if (data.msg === "process_completed") { es.close(); state.setCurrentImageEventSource(null); if (data.success && data.output?.data?.[0]?.url) { const tempUrl = data.output.data[0].url; ui.showStatusUpdate("در حال دائمیسازی لینک...", el); try { const up = await uploadUrlToHuggingFace(tempUrl); const permanentUrl = up.hf_url; const newParts = [{ image_url: permanentUrl }]; if (followUpText) newParts.push({ text: followUpText }); const newModelMessage = { role: 'model', parts: newParts }; activeChat.messages[activeChat.messages.length - 1] = newModelMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(newModelMessage, activeChat.messages.length - 1, { isLastModel: true, animate: false }, el); resolve(); } catch (e) { reject(new Error(`خطای آپلود: ${e.message}`)); } } else reject(new Error(data.output?.error || "URL نامعتبر.")); } if (data.msg === 'queue_full') { es.close(); reject(new Error("صف پر است.")); } }; es.onerror = () => { es.close(); state.setCurrentImageEventSource(null); reject(new Error("قطع ارتباط با سرور تصویر.")); }; }); } catch (e) { if (e.name !== 'AbortError') ui.displayError(el, e.message); else el.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- تولید پاسخ متوقف شد --</p>'; } finally { ui.resetState(); } } export async function streamResponse(modelBubbleOuterDivElement, incomingHistory, chatId, actionPayload = null) { state.setGlobalAbortController(new AbortController()); const activeChat = state.getActiveChat(); let fullResponseText = ""; let finalMessageObject = { role: 'model', parts: [] }; const historyForApi = summarizeHistoryForApi(actionPayload ? incomingHistory.slice(0, -1) : incomingHistory); const inlineFiles = []; for (const msg of incomingHistory) { if (!msg || !msg.parts) continue; for (const p of msg.parts) { if (p.base64Data && p.mimeType) inlineFiles.push({ fileUrl: p.fileUrl || null, mimeType: p.mimeType, data: p.base64Data }); } } async function streamOnce(localActionPayload) { let lastFinishReason = null; const bodyPayload = { history: historyForApi, model: state.getActiveChat().model, chatId, action: localActionPayload || actionPayload, inlineFiles }; const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: state.globalAbortController.signal, body: JSON.stringify(bodyPayload) }); if (!response.ok) { const err = await response.json().catch(()=>({error:{message:`HTTP ${response.status}`}})); throw new Error(err.error?.message || `HTTP ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; async function processEvent(rawEvent) { const lines = rawEvent.split('\n'); const dataPayload = lines.filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).filter(Boolean).join('\n'); if (!dataPayload) return; const data = JSON.parse(dataPayload); const lastUserMessage = activeChat.messages[activeChat.messages.length - 2]; if (data.finishReason) { lastFinishReason = data.finishReason; return; } if (data.intent === 'generate_image') { reader.cancel(); if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = data.english_prompt; lastUserMessage.aspect_ratio_used = data.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } await runExternalImageGenerator(data.english_prompt, data.aspect_ratio, modelBubbleOuterDivElement); return 'break'; } if (data.intent === 'generate_image_with_text') { reader.cancel(); const { text, image_generation_payload, follow_up_text } = data; if (lastUserMessage && lastUserMessage.role === 'user') { lastUserMessage.english_prompt_used = image_generation_payload.english_prompt; lastUserMessage.aspect_ratio_used = image_generation_payload.aspect_ratio; state.saveSessions(); await saveSession(activeChat); } ui.streamFinalText(text, modelBubbleOuterDivElement); await runExternalImageGenerator(image_generation_payload.english_prompt, image_generation_payload.aspect_ratio, modelBubbleOuterDivElement, follow_up_text); return 'break'; } if (data.intent === 'clarify_action') { reader.cancel(); const commandMessage = { role: 'model', clarification: data.options, question: data.question, parts: [] }; activeChat.messages[activeChat.messages.length - 1] = commandMessage; state.saveSessions(); await saveSession(activeChat); ui.addMessageToUI(commandMessage, activeChat.messages.length - 1, { isLastModel: true }, modelBubbleOuterDivElement); ui.resetState(); return 'break'; } if (data.intent === 'edit_image') { reader.cancel(); let fileUrlForEditing = null; for (let i = incomingHistory.length - 2; i >= 0; i--) { const msg = incomingHistory[i]; if (!msg?.parts) continue; const p1 = msg.parts.find(p => p.image_url || (p.edited_images && p.edited_images.length > 0)); if (p1) { fileUrlForEditing = p1.image_url || p1.edited_images[0]; break; } const p2 = msg.parts.find(p => p.fileUrl); if (p2) { fileUrlForEditing = p2.fileUrl; break; } } if (!fileUrlForEditing) { ui.displayError(modelBubbleOuterDivElement, "فایلی برای ویرایش پیدا نشد."); ui.resetState(); return 'break'; } await runExternalImageEditor(data.prompt, fileUrlForEditing, modelBubbleOuterDivElement, activeChat.messages.length - 1); return 'break'; } if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); if (data.candidates) { const c = data.candidates[0] || {}; if (typeof c.finishReason !== 'undefined' && c.finishReason !== null) lastFinishReason = c.finishReason; const parts = (c.content && c.content.parts) || []; const textPart = parts.find(p => typeof p.text === 'string'); if (textPart && textPart.text) { fullResponseText += textPart.text; ui.streamFinalText(fullResponseText, modelBubbleOuterDivElement, false); } } } while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let sepIndex; while ((sepIndex = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, sepIndex).trim(); buffer = buffer.slice(sepIndex + 2); if (!rawEvent) continue; const res = await processEvent(rawEvent); if (res === 'break') return { lastFinishReason: 'BREAK' }; } } if (buffer.trim().length) { const res = await processEvent(buffer.trim()); if (res === 'break') return { lastFinishReason: 'BREAK' }; } return { lastFinishReason }; } try { const firstRun = await streamOnce(null); let reason = firstRun.lastFinishReason || null; let continues = 0; while (reason && reason !== 'STOP' && reason !== 'BREAK' && continues < MAX_AUTO_CONTINUES) { const nextRun = await streamOnce({ intent: 'auto_continue' }); reason = nextRun.lastFinishReason || null; continues += 1; } if (fullResponseText) finalMessageObject.parts = [{ text: fullResponseText }]; if (finalMessageObject.parts.length > 0) activeChat.messages[activeChat.messages.length - 1] = finalMessageObject; else activeChat.messages.pop(); state.saveSessions(); await saveSession(activeChat); ui.updateMessageActions(modelBubbleOuterDivElement, finalMessageObject, false, true); } catch (e) { if (e.name === 'AbortError') { modelBubbleOuterDivElement.querySelector('.message-content-area').innerHTML += '<p class="text-xs text-slate-500 mt-2 text-center">-- عملیات متوقف شد --</p>'; } else { ui.displayError(modelBubbleOuterDivElement, e.message || 'خطا'); } } finally { ui.resetState(); } } export async function loadAllSessionsFromHF() { const r = await fetch(STORAGE_INDEX_URL, { method: 'GET' }); if (!r.ok) return []; const idx = await r.json(); const ids = (idx.items || []).sort((a,b)=>new Date(b.updated_at)-new Date(a.updated_at)).map(x=>x.id); const sessions = []; for (const id of ids) { const sRes = await fetch(`${STORAGE_SESSION_URL}/${encodeURIComponent(id)}`, { method: 'GET' }); if (sRes.ok) sessions.push(await sRes.json()); } return sessions; } export async function saveSession(session) { await fetch(STORAGE_SESSION_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(session) }); } export async function deleteSession(id) { await fetch(`${STORAGE_SESSION_URL}/${encodeURIComponent(id)}`, { method: 'DELETE' }); }
python# app.py import os import re import json import logging from pathlib import Path from flask import Flask, render_template, request, Response, jsonify from filelock import FileLock import requests from huggingface_hub import HfApi, CommitOperationAdd, CommitOperationDelete, create_repo, hf_hub_download class PersianLogFormatter(logging.Formatter): LEVEL_MAP = {logging.DEBUG: "دیباگ", logging.INFO: "اطلاع", logging.WARNING: "هشدار", logging.ERROR: "خطا", logging.CRITICAL: "بحرانی"} def format(self, record): record.levelname = self.LEVEL_MAP.get(record.levelno, record.levelname) return super().format(record) def setup_logging(): formatter = PersianLogFormatter('[%(asctime)s] [%(levelname)s]: %(message)s', datefmt='%Y-%m-%d %H:%M:%S') root = logging.getLogger() if root.hasHandlers(): root.handlers.clear() h = logging.StreamHandler(); h.setFormatter(formatter) root.addHandler(h); root.setLevel(logging.INFO) setup_logging() ALL_GEMINI_API_KEYS_STR = os.getenv('ALL_GEMINI_API_KEYS', '') if not ALL_GEMINI_API_KEYS_STR: raise RuntimeError("ALL_GEMINI_API_KEYS not set") MASTER_API_KEYS = [k.strip() for k in ALL_GEMINI_API_KEYS_STR.split(',') if k.strip()] if not MASTER_API_KEYS: raise RuntimeError("No valid keys") SHM = Path('/dev/shm'); SHM.mkdir(exist_ok=True) COUNTER_FILE_PATH = str(SHM / 'gunicorn_key_counter.txt') lock = FileLock(COUNTER_FILE_PATH + ".lock") with lock: if not os.path.exists(COUNTER_FILE_PATH): with open(COUNTER_FILE_PATH, 'w') as f: f.write('0') CACHE_DIR = SHM / 'file_cache'; CACHE_DIR.mkdir(parents=True, exist_ok=True) META_DIR = SHM / 'chat_meta'; META_DIR.mkdir(parents=True, exist_ok=True) HF_LOCAL_CACHE = SHM / 'hf_ds_cache'; HF_LOCAL_CACHE.mkdir(parents=True, exist_ok=True) HF_TOKEN = os.getenv("HF_TOKEN", "") HF_REPO_ID = os.getenv("HF_DATASET_REPO", "Asrasahar/alpha-chat-history") api_hf = HfApi(token=HF_TOKEN if HF_TOKEN else None) if HF_TOKEN: try: create_repo(repo_id=HF_REPO_ID, repo_type="dataset", token=HF_TOKEN, exist_ok=True) except Exception: pass app = Flask(__name__) GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models' BASE_RULES_FA = """ دستورالعملهای اجباری برای دستیار هوش مصنوعی آلفا: هویت: تو «چتبات آلفا» هستی که توسط هوش مصنوعی آلفا توسعه داده شدهای. تحلیل جامع چندرسانهای: هر نوع فایل را تحلیل و محتوا را منظم شرح بده. اگر فقط فایل ارسال شد و درخواستی برای تولید/ویرایش تصویر نبود، فقط تحلیل کن. اولویت ابزارها: handle_specific_edit > change_image_aspect_ratio > regenerate_with_enhancement > generate_image > perform_internet_search """ def get_and_increment_key_index(): l = FileLock(COUNTER_FILE_PATH + ".lock") with l: try: with open(COUNTER_FILE_PATH, 'r') as f: cur = int(f.read().strip()) except (FileNotFoundError, ValueError): cur = 0 idx = cur % len(MASTER_API_KEYS) with open(COUNTER_FILE_PATH, 'w') as f: f.write(str(cur + 1)) return idx def get_keys_for_request(): s = get_and_increment_key_index() return MASTER_API_KEYS[s:] + MASTER_API_KEYS[:s] def _meta_path(chat_id: str) -> Path: safe = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return META_DIR / f"{safe}.json" def load_chat_meta(chat_id: str) -> dict: p = _meta_path(chat_id); l = FileLock(str(p) + ".lock") with l: if p.exists(): try: return json.load(open(p, 'r', encoding='utf-8')) except Exception: return {} return {} def save_chat_meta(chat_id: str, meta: dict): p = _meta_path(chat_id); l = FileLock(str(p) + ".lock") with l: try: with open(p, 'w', encoding='utf-8') as f: json.dump(meta or {}, f, ensure_ascii=False) except Exception: pass def update_chat_meta(chat_id: str, **kwargs): m = load_chat_meta(chat_id) m.update({k: v for k, v in kwargs.items() if v is not None}) save_chat_meta(chat_id, m) def _file_cache_path(chat_id: str) -> Path: safe = ''.join(c for c in str(chat_id) if c.isalnum() or c in ('-', '_')) return CACHE_DIR / f"{safe}.json" def load_file_cache(chat_id: str) -> dict: p = _file_cache_path(chat_id) if p.exists(): try: return json.load(open(p, 'r', encoding='utf-8')) except Exception: pass return {"files": []} def save_file_cache(chat_id: str, cache: dict): p = _file_cache_path(chat_id); l = FileLock(str(p) + ".lock") with l: try: with open(p, 'w', encoding='utf-8') as f: json.dump(cache or {"files": []}, f, ensure_ascii=False) except Exception: pass def update_file_cache_from_inline_files(chat_id: str, inline_files: list): if not inline_files: return cache = load_file_cache(chat_id) existing = {f.get("fileUrl"): f for f in cache.get("files", []) if f.get("fileUrl")} changed = False for it in inline_files: url = (it or {}).get("fileUrl") mime = (it or {}).get("mimeType") data = (it or {}).get("data") if not (url and mime and data): continue rec = {"fileUrl": url, "inlineData": {"mimeType": mime, "data": data}} if url not in existing or existing[url].get("inlineData", {}).get("data") != data: existing[url] = rec; changed = True if changed: cache["files"] = list(existing.values()) save_file_cache(chat_id, cache) def inject_inline_data_into_history(chat_id: str, history: list) -> list: cache = load_file_cache(chat_id) mapping = {f.get("fileUrl"): f.get("inlineData") for f in cache.get("files", []) if f.get("fileUrl")} if not mapping: return history out = [] for msg in history: if msg.get("role") != "user": out.append(msg); continue parts = msg.get("parts", []) new_parts = [] for p in parts: if isinstance(p, dict) and p.get("fileUrl"): url = p["fileUrl"] if url in mapping: new_parts.append({"inlineData": {"mimeType": mapping[url]["mimeType"], "data": mapping[url]["data"]}}) new_parts.append(p) msg2 = {"role": msg["role"], "parts": new_parts} out.append(msg2) return out def get_all_tools(): search_tool = {"name":"perform_internet_search","description":"جستجوی بهروز","parameters":{"type":"OBJECT","properties":{"query":{"type":"STRING"}},"required":["query"]}} image_tools = [ {"name":"generate_image","description":"ساخت تصویر جدید","parameters":{"type":"OBJECT","properties":{"english_prompt":{"type":"STRING"},"aspect_ratio":{"type":"STRING"},"initial_response_text":{"type":"STRING"},"follow_up_text":{"type":"STRING"}},"required":["english_prompt","initial_response_text","follow_up_text"]}}, {"name":"handle_specific_edit","description":"ویرایش مشخص","parameters":{"type":"OBJECT","properties":{"edit_request":{"type":"STRING"}},"required":["edit_request"]}}, {"name":"regenerate_with_enhancement","description":"بازسازی با بهبود","parameters":{"type":"OBJECT","properties":{"enhancement_request":{"type":"STRING"},"previous_english_prompt":{"type":"STRING"},"previous_aspect_ratio":{"type":"STRING"}},"required":["enhancement_request","previous_english_prompt","previous_aspect_ratio"]}}, {"name":"change_image_aspect_ratio","description":"تغییر نسبت تصویر","parameters":{"type":"OBJECT","properties":{"new_aspect_ratio":{"type":"STRING"},"previous_english_prompt":{"type":"STRING"}},"required":["new_aspect_ratio","previous_english_prompt"]}} ] return [{"function_declarations": image_tools + [search_tool]}] def trim_history(history_for_gemini, max_messages=40): if len(history_for_gemini) <= max_messages: return history_for_gemini keep_idx = set() for i, m in enumerate(history_for_gemini): for p in m.get("parts", []): if isinstance(p, dict) and ("inlineData" in p or "fileUrl" in p): keep_idx.add(i) break tail_start = max(0, len(history_for_gemini) - max_messages) for i in range(tail_start, len(history_for_gemini)): keep_idx.add(i) ordered = [history_for_gemini[i] for i in sorted(keep_idx)] return ordered[-max_messages:] def stream_google_sse(url, payload, include_finish=True): keys = get_keys_for_request() for k in keys: try: with requests.post(url, json=payload, params={'key': k}, stream=True, timeout=720) as r: if r.status_code == 429: continue r.raise_for_status() for line in r.iter_lines(): if not line: continue if not line.startswith(b"data: "): continue raw = line.decode('utf-8')[6:] try: obj = json.loads(raw) except Exception: continue cand = (obj.get("candidates") or [{}])[0] parts = (cand.get("content") or {}).get("parts") or [] for pt in parts: if isinstance(pt, dict) and "text" in pt: yield f"data: {json.dumps({'candidates':[{'content':{'parts':[{'text': pt['text']} ]}}]})}\n\n" if include_finish and "finishReason" in cand and cand["finishReason"] is not None: yield f"data: {json.dumps({'finishReason': cand['finishReason']})}\n\n" return except requests.exceptions.RequestException: continue yield f"data: {json.dumps({'error': {'code':'UPSTREAM_FAILED','message':'ارتباط با مدل برقرار نشد.'}})}\n\n" def classify_user_intent(user_text: str) -> dict: if not user_text or not user_text.strip(): return {"intent":"NONE"} model = 'gemini-1.5-flash' url = f"{GOOGLE_API_BASE_URL}/{model}:generateContent" system = "Return strict JSON: {\"intent\":\"...\", \"normalized_edit\":\"...\", \"new_aspect_ratio\":\"...\", \"code_language\":\"...\"}. intents=[NONE,SPECIFIC_EDIT,ASPECT_RATIO_CHANGE,QUALITY_ENHANCEMENT,NEW_IMAGE,CODE_TASK]." payload = {"contents":[{"role":"user","parts":[{"text": user_text}]}],"systemInstruction":{"parts":[{"text": system}]},"generationConfig":{"temperature":0.0,"maxOutputTokens":128}} for k in get_keys_for_request(): try: r = requests.post(f"{url}?key={k}", json=payload, timeout=25) if r.status_code == 429: continue r.raise_for_status() txt = r.json()["candidates"][0]["content"]["parts"][0]["text"].strip().strip('`').strip() s, e = txt.find('{'), txt.rfind('}') if s != -1 and e != -1: txt = txt[s:e+1] d = json.loads(txt) if "intent" not in d: return {"intent":"NONE"} return d except Exception: continue return {"intent":"NONE"} def stream_text_only(user_text: str, model_name: str): url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse" payload = {"contents":[{"role":"user","parts":[{"text": user_text }]}], "tools": None, "systemInstruction": {"parts": [{"text": BASE_RULES_FA + "\n[TURN MODE: TEXT ONLY]\n"}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}} yield from stream_google_sse(url, payload, include_finish=True) def stream_code_reply(user_text: str, model_name: str, code_language: str = None): url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse" payload = {"contents":[{"role":"user","parts":[{"text": user_text }]}], "tools": None, "systemInstruction": {"parts": [{"text": BASE_RULES_FA + "\n[TURN MODE: CODE]\n"}]}, "generationConfig": {"temperature": 0.2, "maxOutputTokens": 4096}} yield from stream_google_sse(url, payload, include_finish=True) def stream_file_analysis(history_for_gemini, model_name: str): url = f"{GOOGLE_API_BASE_URL}/{model_name}:streamGenerateContent?alt=sse" payload = {"contents": history_for_gemini, "tools": None, "systemInstruction": {"parts": [{"text": BASE_RULES_FA + "\n[TURN MODE: FILE ANALYSIS]\n"}]}, "generationConfig": {"temperature": 0.3, "maxOutputTokens": 8192}} yield from stream_google_sse(url, payload, include_finish=True) def stream_search_results(query): model = 'gemini-2.5-flash' url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse" payload = {"contents":[{"role":"user","parts":[{"text": query}]}], "tools":[{"google_search":{}}], "systemInstruction":{"parts":[{"text":"Answer in Persian."}]}, "generationConfig":{"temperature":0.2,"maxOutputTokens":2048}} yield from stream_google_sse(url, payload, include_finish=True) @app.route('/') def index(): return render_template('index.html') @app.route('/storage/index', methods=['GET']) def storage_index(): try: try: path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) data = json.load(open(path, 'r', encoding='utf-8')) except Exception: data = {"items": []} return jsonify(data) except Exception: return jsonify({"items": []}) @app.route('/storage/session/<chat_id>', methods=['GET', 'DELETE']) def storage_session_get_delete(chat_id): if request.method == 'GET': try: path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename=f"sessions/{chat_id}.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) data = json.load(open(path, 'r', encoding='utf-8')) return jsonify(data) except Exception: return jsonify({"error":"not_found"}), 404 else: try: try: idx_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) index_data = json.load(open(idx_path, 'r', encoding='utf-8')) except Exception: index_data = {"items": []} index_data["items"] = [it for it in index_data.get("items", []) if it.get("id") != chat_id] ops = [CommitOperationDelete(path_in_repo=f"sessions/{chat_id}.json"), CommitOperationAdd(path_in_repo="index.json", path_or_fileobj=json.dumps(index_data, ensure_ascii=False).encode('utf-8'))] if HF_TOKEN: api_hf.create_commit(repo_id=HF_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"delete session {chat_id}", token=HF_TOKEN) return jsonify({"ok": True}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 @app.route('/storage/session', methods=['POST']) def storage_session_upsert(): try: session = request.get_json(force=True) if not session or "id" not in session: return jsonify({"ok": False, "error": "invalid session"}), 400 chat_id = str(session["id"]) title = session.get("title","") from datetime import datetime, timezone updated_at = datetime.now(timezone.utc).isoformat() try: idx_path = hf_hub_download(repo_id=HF_REPO_ID, repo_type="dataset", filename="index.json", token=HF_TOKEN, local_dir=str(HF_LOCAL_CACHE), local_dir_use_symlinks=False) index_data = json.load(open(idx_path, 'r', encoding='utf-8')) except Exception: index_data = {"items": []} items = [it for it in index_data.get("items", []) if it.get("id") != chat_id] items.append({"id": chat_id, "title": title or f"chat-{chat_id}", "updated_at": updated_at}) index_data["items"] = items ops = [ CommitOperationAdd(path_in_repo=f"sessions/{chat_id}.json", path_or_fileobj=json.dumps(session, ensure_ascii=False).encode('utf-8')), CommitOperationAdd(path_in_repo="index.json", path_or_fileobj=json.dumps(index_data, ensure_ascii=False).encode('utf-8')) ] if HF_TOKEN: api_hf.create_commit(repo_id=HF_REPO_ID, repo_type="dataset", operations=ops, commit_message=f"upsert session {chat_id}", token=HF_TOKEN) return jsonify({"ok": True}) except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 def build_history(history): out = [] for msg in history: if 'role' not in msg: continue new_msg = {'role': msg['role'], 'parts': []} for part in (msg.get('parts') or []): if isinstance(part, dict) and any(k in part for k in ['text','inlineData','functionCall','functionResponse','image_url','edited_images','fileUrl','mimeType','name']): if 'image_url' in part or 'edited_images' in part: new_msg['parts'].append({'text': '(تصویر در پیامهای قبلی ساخته/ویرایش شده است.)'}) elif 'fileUrl' in part and 'mimeType' in part and not part.get('mimeType','').startswith('image/'): new_msg['parts'].append({'text': f"(فایل با نوع {part.get('mimeType')} قبلاً ارسال شده است.)"}) elif 'text' in part or 'inlineData' in part: new_msg['parts'].append(part) if new_msg['parts']: out.append(new_msg) return out @app.route('/chat', methods=['POST']) def chat(): data = request.json or {} model = data.get('model', 'gemini-2.5-flash') history = data.get('history', []) action_payload = data.get('action') chat_id = data.get('chatId') inline_files = data.get('inlineFiles', []) if not chat_id: return Response(json.dumps({"error": "chatId is required"}), status=400, mimetype='application/json') try: update_file_cache_from_inline_files(chat_id, inline_files) except Exception: pass def stream_events(): def send_event(event_data): return f"data: {json.dumps(event_data)}\n\n" history_for_gemini = build_history(history) history_for_gemini = inject_inline_data_into_history(chat_id, history_for_gemini) if action_payload and action_payload.get('intent') == 'auto_continue': history_for_gemini.append({"role":"user","parts":[{"text":"ادامه بده از همانجا که پاسخ قبلی قطع شد. تکرار نکن و فقط ادامه را بنویس."}]}) history_for_gemini = trim_history(history_for_gemini, max_messages=40) last_user_idx_local = -1 for i in range(len(history_for_gemini) - 1, -1, -1): if history_for_gemini[i].get('role') == 'user': last_user_idx_local = i; break last_user_text = ""; has_inline_file = False if last_user_idx_local != -1: lu = history_for_gemini[last_user_idx_local] last_user_text = ''.join(p.get('text','') for p in (lu.get('parts') or []) if p.get('text')).strip() has_inline_file = any('inlineData' in p for p in (lu.get('parts') or [])) image_in_recent_history = any(m.get('role') == 'model' and any('image_url' in p or 'edited_images' in p for p in (m.get('parts') or [])) for m in history[-4:]) intent_info = classify_user_intent(last_user_text) if last_user_text else {"intent":"NONE"} intent = (intent_info.get("intent") or "NONE").upper() if not (action_payload and action_payload.get('intent') == 'auto_continue'): if intent == "CODE_TASK": yield from stream_code_reply(last_user_text, model); return if image_in_recent_history and last_user_text and intent == "NONE": yield from stream_text_only(last_user_text, model); return if has_inline_file and intent in {"NONE"}: yield from stream_file_analysis(history_for_gemini, model); return if image_in_recent_history and last_user_text: if intent == "SPECIFIC_EDIT": merged = (intent_info.get("normalized_edit") or last_user_text) update_chat_meta(chat_id, last_edit_prompt=merged) yield send_event({"intent": "edit_image", "prompt": merged}); return elif intent == "ASPECT_RATIO_CHANGE": meta = load_chat_meta(chat_id) ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" new_ar = intent_info.get("new_aspect_ratio") or meta.get("last_aspect_ratio") or "9:16" yield send_event({"intent": "generate_image", "english_prompt": ep, "aspect_ratio": new_ar}); return elif intent == "QUALITY_ENHANCEMENT": meta = load_chat_meta(chat_id) prev_ep = meta.get("last_edit_prompt") or meta.get("last_english_prompt") or "" prev_ar = meta.get("last_aspect_ratio") or "9:16" enh = intent_info.get("normalized_edit") or last_user_text yield send_event({"intent": "clarify_action", "question": "تصویر فعلی را ویرایش کنم یا تصویر جدید بسازم؟", "options":{"edit":{"label":"ویرایش همین تصویر","intent":"edit_image","prompt": enh}, "regenerate":{"label":"ساخت تصویر جدید","intent":"regenerate_with_enhancement","base_english_prompt": prev_ep,"enhancement_request": enh,"aspect_ratio": prev_ar}}}); return elif intent == "NEW_IMAGE": yield send_event({"intent": "generate_image_with_text", "text": "در حال ساخت تصویر جدید برای شما...", "image_generation_payload": {"english_prompt": last_user_text, "aspect_ratio": "9:16"}, "follow_up_text": "تصویر شما آماده شد!"}); return tools_for_request = get_all_tools() url = f"{GOOGLE_API_BASE_URL}/{model}:streamGenerateContent?alt=sse" payload = {"contents": history_for_gemini, "tools": tools_for_request, "systemInstruction": {"parts": [{"text": BASE_RULES_FA + "\n[TURN MODE: GENERAL]\n"}]}, "generationConfig":{"temperature":0.3,"maxOutputTokens":8192}} for line in stream_google_sse(url, payload, include_finish=True): yield line return Response(stream_events(), mimetype='text/event-stream') if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=os.environ.get("PORT", 7860))