File size: 21,837 Bytes
ada959a
c794519
951b2dd
8f44aff
e796be6
 
 
 
 
 
 
8f44aff
 
f33a68d
ef58159
0834fe1
ef58159
 
f33a68d
0834fe1
ef58159
2cacd8b
ad84a4e
 
 
e796be6
ad84a4e
 
e796be6
 
 
 
 
 
 
4248f42
 
e796be6
2cacd8b
ed72a14
2cacd8b
0834fe1
2cacd8b
 
 
f33a68d
 
 
 
 
 
0834fe1
 
 
 
 
 
2cacd8b
 
c6eb002
 
2cacd8b
 
c6eb002
ad84a4e
d1892ca
 
 
 
ad84a4e
 
 
 
 
e2e06b0
ad84a4e
c6eb002
f33a68d
ad84a4e
 
 
 
 
 
 
f33a68d
 
ad84a4e
 
 
 
 
 
 
 
f33a68d
ad84a4e
 
c6eb002
 
 
 
 
 
ad84a4e
c6eb002
c794519
e796be6
c794519
e796be6
 
 
 
 
c794519
e796be6
 
 
 
 
 
 
c794519
e796be6
 
 
 
 
 
 
 
 
 
 
 
 
c794519
 
e796be6
 
 
 
c794519
e796be6
c794519
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a8062d
c794519
e796be6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c794519
c6eb002
30e54a3
 
 
0834fe1
 
30e54a3
0834fe1
30e54a3
0834fe1
ad84a4e
 
 
 
 
 
 
 
 
 
 
d1892ca
 
ad84a4e
 
 
 
 
 
 
 
d1892ca
 
ad84a4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cacd8b
 
 
 
ef58159
 
2cacd8b
 
30e54a3
2cacd8b
 
 
8f44aff
ada959a
ef58159
8f44aff
30e54a3
ef58159
c794519
ef58159
 
 
 
 
 
c794519
ef58159
30e54a3
c6eb002
c794519
 
8f44aff
ada959a
 
2cacd8b
ada959a
 
 
 
2cacd8b
 
ada959a
2cacd8b
ada959a
8f44aff
 
ef58159
0834fe1
 
544ee8f
 
8f44aff
ef58159
8f44aff
ef58159
c794519
 
 
 
2cacd8b
ef58159
c794519
 
 
 
2cacd8b
ef58159
2cacd8b
 
c794519
 
 
8f44aff
 
ef58159
8f44aff
2cacd8b
c794519
8f44aff
ada959a
c794519
 
 
 
8f44aff
ad84a4e
 
8f44aff
c794519
 
 
 
 
 
 
 
 
 
 
 
 
 
8f44aff
c794519
 
 
8f44aff
 
ada959a
8f44aff
d449a13
ada959a
2cacd8b
8f44aff
 
ada959a
 
 
6a8062d
ada959a
 
8f44aff
ef58159
2cacd8b
ada959a
 
2cacd8b
8f44aff
825c8ee
8f44aff
ada959a
 
8f44aff
 
2cacd8b
30e54a3
8f44aff
 
 
2cacd8b
30e54a3
8f44aff
 
ada959a
8f44aff
2a8cf74
0834fe1
 
c794519
 
ef58159
9da4b8a
c794519
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
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 : أقصى عدد رموز يُسمح به لكواليس التفكير. إن نفدت دون أن يُغلق
#                    النموذج التفكير (</think>) نُغلقه قسراً وننتقل لتوليد الجواب.
#   - 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()

# ==========================================
# معيار إيقاف: نوقف التوليد فور إنتاج رمز نهاية التفكير </think>
# ==========================================
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}<details><summary>🧠 كواليس تفكير النموذج</summary>\n\n{thinking}\n\n</details>\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> أو نفاد 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> أو نفاد الميزانية ----
    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("</think>")[0].replace("<think>", "").strip("\n")
            yield _compose_output(badge, thinking_live, "")
        else:
            think_out = payload

    thinking_text = think_raw.split("</think>")[0].replace("<think>", "").strip("\n")

    # ---- المرحلة 2: نضمن إغلاق التفكير ثم نولّد الجواب بميزانية مستقلة ----
    gen_ids = think_out
    if THINK_END_ID not in gen_ids[0].tolist():
        # نفدت ميزانية التفكير دون إغلاق → نُغلقه قسراً بإلحاق </think>
        close = tok("</think>\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 "</details>" in content:                                  # إزالة صندوق التفكير (Qwen)
        content = content.split("</details>")[-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)