import os from threading import Thread import gradio as gr import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer, StoppingCriteria, StoppingCriteriaList, ) from openai import OpenAI # ========================================== # النماذج المتاحة (مساران): # 1) Qwen3.5-2B → عبر Transformers، يدعم وضع التفكير (thinking) القابل للتبديل. # 2) GPT-5 → عبر الـ OpenAI API. # النموذج المحلي يُحمّل كسولاً عند أول استخدام لتوفير الذاكرة عند الإقلاع. # ========================================== QWEN_MODEL = "Qwen/Qwen3.5-2B" GPT_LABEL = "GPT-5 (OpenAI API)" # حدود الرموز (Tokens): # - رسالة المستخدم الواحدة: 512 رمزاً كحد أقصى (يُقصّ الزائد). # - كامل سياق المحادثة المُرسل للنموذج: 4096 رمزاً (نُبقي الأحدث). MAX_MSG_TOKENS = 1024 MAX_CHAT_TOKENS = 4096 # ميزانيتان منفصلتان للتوليد في وضع التفكير: # - THINK_BUDGET : أقصى عدد رموز يُسمح به لكواليس التفكير. إن نفدت دون أن يُغلق # النموذج التفكير () نُغلقه قسراً وننتقل لتوليد الجواب. # - ANSWER_BUDGET: ميزانية مستقلة للجواب، فلا يبتلع التفكيرُ حصة الجواب أبداً. # بهذا نضمن وصول جواب فعلي حتى لو أطال النموذج في التفكير. THINK_BUDGET =512 ANSWER_BUDGET =512 THINK_END_ID = 248069 _qwen_models = {} # سجلّ نماذج Qwen المحمّلة عبر Transformers # ------------------------------------------ # تحميل Qwen عبر Transformers (bfloat16 على الـ CPU لتقليل ذروة الذاكرة) # ------------------------------------------ def get_qwen_model(model_id): if model_id not in _qwen_models: print(f"⏳ جاري تحميل نموذج {model_id} ...") tok = AutoTokenizer.from_pretrained(model_id) if torch.cuda.is_available(): # على الـ GPU: تحميل عادي بالدقة التلقائية mdl = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype="auto", device_map="auto") else: # على الـ CPU: نحمّل بدقة bfloat16 بدون تكميم int8. # السبب: تكميم float32→int8 يُحدث ذروة ذاكرة ضخمة (~10-11GB لنموذج 2B) تسبب OOM # على الـ Space المجاني. تحميل bfloat16 يُبقي الذروة عند ~نصف ذلك فتتّسع النماذج الأكبر، # مقابل توليد أبطأ قليلاً على الـ CPU (لا تسريع int8). print("🔻 تحميل بدقة bfloat16 على CPU لتقليل ذروة استهلاك الذاكرة...") mdl = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, low_cpu_mem_usage=True) _qwen_models[model_id] = (tok, mdl) return _qwen_models[model_id] # ========================================== # توليد Qwen عبر Transformers مع دعم وضع التفكير القابل للتبديل # Qwen3 يوصي: وضع التفكير temp=0.6/top_p=0.95 — الوضع العادي temp=0.7/top_p=0.8 — وtop_k=20 دائماً # ========================================== def _truncate_text_tokens(tok, text, max_tokens): """يقصّ النص إلى max_tokens رمزاً كحد أقصى (نُبقي البداية). نتجاهل ما ليس نصاً (None/غير str) تماماً كما يفعل قالب المحادثة، تفادياً لخطأ المُرمِّز.""" if not isinstance(text, str) or not text: return text ids = tok(text, add_special_tokens=False).input_ids if len(ids) <= max_tokens: return text return tok.decode(ids[:max_tokens], skip_special_tokens=True) def qwen_generate(messages, enable_thinking, tok, mdl, max_new_tokens=1024): temperature = 0.6 if enable_thinking else 0.7 top_p = 0.95 if enable_thinking else 0.8 device = next(mdl.parameters()).device # حدّ رسالة المستخدم: نقصّ كل رسالة مستخدم إلى MAX_MSG_TOKENS رمزاً قبل بناء البرومبت messages = [dict(m) for m in messages] for m in messages: if m["role"] == "user": m["content"] = _truncate_text_tokens(tok, m["content"], MAX_MSG_TOKENS) text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=enable_thinking) model_inputs = tok([text], return_tensors="pt").to(device) # حدّ سياق المحادثة: إن تجاوز الإدخال MAX_CHAT_TOKENS نُبقي آخر (الأحدث) رموزه فقط input_ids = model_inputs.input_ids attention_mask = model_inputs.attention_mask if input_ids.shape[-1] > MAX_CHAT_TOKENS: input_ids = input_ids[:, -MAX_CHAT_TOKENS:] attention_mask = attention_mask[:, -MAX_CHAT_TOKENS:] generated_ids = mdl.generate( input_ids=input_ids, attention_mask=attention_mask, max_new_tokens=max_new_tokens, do_sample=True, temperature=temperature, top_p=top_p, top_k=20, ) return generated_ids[0][len(input_ids[0]):].tolist() # ========================================== # معيار إيقاف: نوقف التوليد فور إنتاج رمز نهاية التفكير # ========================================== class _StopOnToken(StoppingCriteria): def __init__(self, token_id): self.token_id = token_id def __call__(self, input_ids, scores, **kwargs): return input_ids[0, -1].item() == self.token_id # ========================================== # صياغة العرض: نغلّف التفكير في صندوق قابل للطي ثم نُلحق الجواب # ========================================== def _compose_output(badge, thinking, answer): if thinking: return f"{badge}
🧠 كواليس تفكير النموذج\n\n{thinking}\n\n
\n\n{answer}" return f"{badge}{answer}" # دالة مساعدة: تُشغّل التوليد على خيط منفصل وتبثّ النص، وتُعيد تسلسل الرموز الكامل def _run_stream(mdl, tok, gen_kwargs): streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True) gen_kwargs = dict(gen_kwargs, streamer=streamer) box = {} def _run(): box["out"] = mdl.generate(**gen_kwargs) thread = Thread(target=_run, daemon=True) thread.start() for new_text in streamer: yield ("chunk", new_text) thread.join() yield ("done", box.get("out")) # ========================================== # نسخة بثّية من توليد Qwen بميزانيتين منفصلتين (تفكير/جواب). # في وضع التفكير: مرحلة 1 تولّد التفكير حتى أو نفاد THINK_BUDGET، # ثم نُغلق التفكير قسراً ونشغّل مرحلة 2 لتوليد الجواب بميزانية ANSWER_BUDGET # مستقلة — فلا يبتلع التفكيرُ حصةَ الجواب أبداً. # ========================================== def qwen_generate_stream(messages, enable_thinking, tok, mdl, badge): temperature = 0.6 if enable_thinking else 0.7 top_p = 0.95 if enable_thinking else 0.8 device = next(mdl.parameters()).device # حدّ رسالة المستخدم: نقصّ كل رسالة مستخدم إلى MAX_MSG_TOKENS رمزاً قبل بناء البرومبت messages = [dict(m) for m in messages] for m in messages: if m["role"] == "user": m["content"] = _truncate_text_tokens(tok, m["content"], MAX_MSG_TOKENS) text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=enable_thinking) model_inputs = tok([text], return_tensors="pt").to(device) # حدّ سياق المحادثة: إن تجاوز الإدخال MAX_CHAT_TOKENS نُبقي آخر (الأحدث) رموزه فقط input_ids = model_inputs.input_ids attention_mask = model_inputs.attention_mask if input_ids.shape[-1] > MAX_CHAT_TOKENS: input_ids = input_ids[:, -MAX_CHAT_TOKENS:] attention_mask = attention_mask[:, -MAX_CHAT_TOKENS:] sampling = dict(do_sample=True, temperature=temperature, top_p=top_p, top_k=20,repetition_penalty=1.05) # ---- الوضع العادي (بلا تفكير): مرحلة واحدة لتوليد الجواب ---- if not enable_thinking: raw = "" for kind, payload in _run_stream(mdl, tok, dict( input_ids=input_ids, attention_mask=attention_mask, max_new_tokens=ANSWER_BUDGET, **sampling, )): if kind == "chunk": raw += payload yield _compose_output(badge, "", raw.strip("\n")) yield _compose_output(badge, "", raw.strip("\n") or "⚠️ لم يصل أي جواب.") return # ---- وضع التفكير: مرحلة 1 — توليد التفكير حتى أو نفاد الميزانية ---- think_out = None think_raw = "" for kind, payload in _run_stream(mdl, tok, dict( input_ids=input_ids, attention_mask=attention_mask, max_new_tokens=THINK_BUDGET, stopping_criteria=StoppingCriteriaList([_StopOnToken(THINK_END_ID)]), **sampling, )): if kind == "chunk": think_raw += payload # نعرض التفكير مباشرة داخل الصندوق أثناء توليده (الجواب لم يبدأ بعد) thinking_live = think_raw.split("")[0].replace("", "").strip("\n") yield _compose_output(badge, thinking_live, "") else: think_out = payload thinking_text = think_raw.split("")[0].replace("", "").strip("\n") # ---- المرحلة 2: نضمن إغلاق التفكير ثم نولّد الجواب بميزانية مستقلة ---- gen_ids = think_out if THINK_END_ID not in gen_ids[0].tolist(): # نفدت ميزانية التفكير دون إغلاق → نُغلقه قسراً بإلحاق close = tok("\n\n", add_special_tokens=False, return_tensors="pt").input_ids.to(device) gen_ids = torch.cat([gen_ids, close], dim=-1) attn2 = torch.ones_like(gen_ids) answer_raw = "" for kind, payload in _run_stream(mdl, tok, dict( input_ids=gen_ids, attention_mask=attn2, max_new_tokens=ANSWER_BUDGET, **sampling, )): if kind == "chunk": answer_raw += payload yield _compose_output(badge, thinking_text, answer_raw.strip("\n")) answer = answer_raw.strip("\n") or "⚠️ لم يكتمل الجواب ضمن حدّ التوليد. جرّب رفع ANSWER_BUDGET أو إعادة المحاولة." yield _compose_output(badge, thinking_text, answer) # ========================================== # تحميل مسبق للنموذج عند بدء التشغيل بدل التحميل الكسول # نحمّل Qwen مقدّماً حتى لا يتأخر أول ردّ للمستخدم. # (تبقى دالة get_qwen_model كما هي فتجد النموذج محمّلاً في سجلّه) # ========================================== def preload_models(): print("🚀 تحميل مسبق للنموذج (Qwen)...") get_qwen_model(QWEN_MODEL) print("✅ اكتمل التحميل المسبق للنموذج.") # ========================================== # حدود الرموز لمسار OpenAI: نعدّ الرموز بـ tiktoken، ومع بديل تقريبي إن لم تتوفّر # ========================================== try: import tiktoken _OAI_ENC = tiktoken.get_encoding("o200k_base") # ترميز نماذج GPT الحديثة except Exception: _OAI_ENC = None def _oai_truncate(text, max_tokens): """يقصّ نصاً إلى max_tokens رمزاً (بـ tiktoken، أو تقدير ~4 أحرف/رمز كبديل).""" if not isinstance(text, str) or not text: return text if _OAI_ENC is None: return text if len(text) <= max_tokens * 4 else text[: max_tokens * 4] ids = _OAI_ENC.encode(text) if len(ids) <= max_tokens: return text return _OAI_ENC.decode(ids[:max_tokens]) def _oai_count(text): if not isinstance(text, str) or not text: return 0 return len(_OAI_ENC.encode(text)) if _OAI_ENC is not None else max(1, len(text) // 4) def _oai_fit_messages(messages, max_tokens): """يقصّ كل رسالة مستخدم إلى MAX_MSG_TOKENS، ويُبقي المحادثة ضمن max_tokens عبر إسقاط أقدم الرسائل مع الحفاظ على رسالة النظام.""" msgs = [dict(m) for m in messages] for m in msgs: if m["role"] == "user": m["content"] = _oai_truncate(m["content"], MAX_MSG_TOKENS) system = msgs[0] if msgs and msgs[0]["role"] == "system" else None body = msgs[1:] if system else msgs total = lambda: (_oai_count(system["content"]) if system else 0) + sum(_oai_count(m["content"]) for m in body) while total() > max_tokens and len(body) > 1: body.pop(0) # نُسقط الأقدم (نُبقي الأحدث) return ([system] if system else []) + body # ========================================== # تنظيف رسائل المساعد القديمة من الشارات/كواليس التفكير قبل تمريرها للنموذج # ========================================== def _strip_assistant_extras(content): if not isinstance(content, str): return "" if "" in content: # إزالة صندوق التفكير (Qwen) content = content.split("")[-1].strip() if content.startswith("🟢") and "\n\n" in content: # إزالة شارة المسار content = content.split("\n\n", 1)[-1].strip() return content # ========================================== # الدالة الأساسية لمعالجة المحادثة بنظام الـ Messages الحديث # (غلاف يلتقط أي خطأ ويعرضه نصاً في الشات بدل شارة Gradio العامة "Error") # ========================================== def chat_engine(user_input, chat_history, model_choice, enable_thinking, api_key, system_prompt): try: yield from _chat_engine_impl(user_input, chat_history, model_choice, enable_thinking, api_key, system_prompt) except Exception as e: import traceback traceback.print_exc() if chat_history is None: chat_history = [] chat_history.append({"role": "assistant", "content": f"❌ خطأ عام في المعالجة:\n\n`{type(e).__name__}: {str(e)}`"}) yield chat_history, "" def _chat_engine_impl(user_input, chat_history, model_choice, enable_thinking, api_key, system_prompt): if not user_input.strip(): yield chat_history, "" return # 1. تهيئة مصفوفة الرسائل ببرومبت النظام (System Prompt) messages = [{"role": "system", "content": system_prompt}] # 2. تفكيك التاريخ القديم بصيغة الـ Dicts الجديدة for msg in chat_history: role = msg["role"] content = msg["content"] if role == "assistant": content = _strip_assistant_extras(content) messages.append({"role": role, "content": content}) # 3. إضافة رسالة المستخدم الجديدة الحالية messages.append({"role": "user", "content": user_input}) # 4. تقليم التاريخ: نُبقي آخر تبادل واحد فقط (سؤال+جواب) حتى لا يتضخّم السياق MAX_HISTORY_MSGS = 4 if len(messages) > MAX_HISTORY_MSGS : messages = [messages[0]] + messages[-(MAX_HISTORY_MSGS + 1):] # ------------------------------------------ # المسار الأول: نموذج Qwen3 المحلي # ------------------------------------------ if model_choice == QWEN_MODEL: badge = "🟢 *ردّ مباشر من Qwen*\n\n" # نضيف رسالة المستخدم ورسالة مساعد فارغة نُحدّثها تدريجياً مع تدفّق الرموز chat_history.append({"role": "user", "content": user_input}) chat_history.append({"role": "assistant", "content": badge}) try: tok, mdl = get_qwen_model(model_choice) # البثّ: كل دفعة نُحدّث آخر رسالة مساعد ونُنتج الحالة الجديدة للعرض الحيّ for final_output in qwen_generate_stream(messages, enable_thinking, tok, mdl, badge): chat_history[-1]["content"] = final_output yield chat_history, "" except Exception as e: # نُظهر الخطأ الفعلي بدل رسالة Gradio العامة حتى يسهل تشخيصه import traceback traceback.print_exc() chat_history[-1]["content"] = f"❌ خطأ أثناء تشغيل النموذج المحلي ({model_choice}):\n\n`{type(e).__name__}: {str(e)}`" yield chat_history, "" return # ------------------------------------------ # المسار الثاني: GPT-5 عبر الـ API # ------------------------------------------ elif model_choice == GPT_LABEL: chat_history.append({"role": "user", "content": user_input}) if not api_key.strip(): chat_history.append({"role": "assistant", "content": "⚠️ يرجى إدخال الـ API Key أولاً."}) yield chat_history, "" return # رسالة مساعد فارغة نُراكم عليها دفعات البثّ chat_history.append({"role": "assistant", "content": ""}) try: # نطبّق حدّي الرموز: 512 لكل رسالة مستخدم و4096 لكامل السياق messages = _oai_fit_messages(messages, MAX_CHAT_TOKENS) client = OpenAI(api_key=api_key) # stream=True: نستقبل الردّ على شكل دفعات ونعرضها فور وصولها stream = client.chat.completions.create( model="gpt-5", messages=messages, max_tokens=1024, stream=True ) acc = "" for chunk in stream: delta = chunk.choices[0].delta.content or "" if delta: acc += delta chat_history[-1]["content"] = acc yield chat_history, "" if not acc: chat_history[-1]["content"] = "⚠️ لم يصل أي محتوى من الـ API." yield chat_history, "" except Exception as e: chat_history[-1]["content"] = f"❌ خطأ: {str(e)}" yield chat_history, "" return # ========================================== # بناء واجهة المستخدم (Gradio UI Layout) # ========================================== with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="slate")) as demo: gr.Markdown("# 🤖 منصة المحادثة الذكية متعددة النماذج") with gr.Row(): with gr.Column(scale=1, min_width=280): gr.Markdown("### ⚙️ إعدادات النظام") system_prompt_input = gr.Textbox( label="توجيهات النظام (System Prompt)", value="you are a funny smart assistant. you anwer briefly and accurately and in standard Arabic", lines=4, placeholder="اكتب تعليمات الموديل هنا..." ) model_dropdown = gr.Dropdown(choices=[QWEN_MODEL, GPT_LABEL], value=QWEN_MODEL, label="اختر النموذج") thinking_checkbox = gr.Checkbox(label="🧠 تفعيل وضع التفكير (Qwen فقط)", value=True) api_key_input = gr.Textbox(label="OpenAI API Key", type="password") clear_button = gr.Button("🧹 مسح الذاكرة") with gr.Column(scale=4): chatbot = gr.Chatbot(label="شاشة الشات", height=550) with gr.Row(): user_msg = gr.Textbox(placeholder="اكتب سؤالك هنا واضغط Enter...", scale=4) submit_button = gr.Button("إرسال 🚀", variant="primary", scale=1) submit_event = submit_button.click( chat_engine, inputs=[user_msg, chatbot, model_dropdown, thinking_checkbox, api_key_input, system_prompt_input], outputs=[chatbot, user_msg] ) user_msg.submit( chat_engine, inputs=[user_msg, chatbot, model_dropdown, thinking_checkbox, api_key_input, system_prompt_input], outputs=[chatbot, user_msg] ) clear_button.click(fn=lambda: [], outputs=chatbot, queue=False) if __name__ == "__main__": # تحميل النماذج مقدّماً قبل رفع الواجهة حتى يكون أول ردّ سريعاً. preload_models() # نفعّل الطابور (queue) حتى يعمل البثّ التدريجي للدوال المُولّدة (generators) demo.queue() # على Colab نحتاج share=True لإنشاء رابط عام؛ على Hugging Face Space يجب ألا نفعّله. on_hf_space = os.environ.get("SPACE_ID") is not None demo.launch(share=not on_hf_space)