File size: 34,742 Bytes
213932c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d52555e
 
 
 
213932c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193a0cd
213932c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
# -*- coding: utf-8 -*-
"""
🎃 Pumpkin AI Console — Full Version (Text + Image + Hint System)
Author: Cherry Leung, Jay Luk
Date: 2025-10-13
"""

import sys
import os
import re
import base64
import random
import unicodedata as _ud  # for robust Han-only normalization
from pathlib import Path
import openai
import logging
from datetime import datetime

# Try to load .env file if python-dotenv is available
try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass  # .env file won't be loaded automatically, use system environment variables

# ========================
#  LOGGING SETUP
# ========================

# Configure logging to show DEBUG level messages with timestamp
logging.basicConfig(
    level=logging.DEBUG,  # Changed to DEBUG for detailed logging
    format='%(asctime)s [%(levelname)s] %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)

# ========================
#  CONFIGURATION
# ========================

POE_API_KEY = os.getenv("POE_API_KEY")
if not POE_API_KEY:
    logger.error("POE_API_KEY environment variable not set!")
    raise ValueError("POE_API_KEY environment variable is required. Please set it in HuggingFace Space Secrets.")

BASE_URL = "https://api.poe.com/v1"

TEXT_MODEL  = "gpt-4o-mini"
IMAGE_MODEL = "Nano-Banana"  # Switched to Nano-Banana for better pumpkin integration
TIMEOUT = 60

logger.info(f"Initializing OpenAI client with base URL: {BASE_URL}")
client = openai.OpenAI(api_key=POE_API_KEY, base_url=BASE_URL)
logger.info("OpenAI client initialized successfully")

# ========================
# REGEX
# ========================

STATUS_NO_RX   = re.compile(r"\{status(?:\s*code)?:\s*no\}", re.IGNORECASE)
STATUS_YES_RX  = re.compile(r"\{status(?:\s*code)?:\s*yes\}", re.IGNORECASE)
DATA_URL_RX    = re.compile(r"data:image/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)", re.IGNORECASE)

# ========================
#  PROMPTS / HINTS / RESCUE LINES
# ========================

def load_text(path: Path) -> str:
    """Read text file safely (UTF-8-SIG to remove BOM)."""
    logger.info(f"Loading text file: {path}")
    return path.read_text(encoding="utf-8-sig", errors="ignore")

def load_prompts(prompt_dir: Path):
    classify = load_text(prompt_dir / "text_or_image_classification.txt")
    textrep  = load_text(prompt_dir / "text_replies.txt")
    imagerep = load_text(prompt_dir / "image_replies.txt")
    hintsraw = load_text(prompt_dir / "hints.txt")
    return classify, textrep, imagerep, hintsraw

def load_hints_simple(file_path: str, limit: int = 10) -> list[str]:
    """
    Read hints.txt formatted as one hint per line.
    Lines starting with # or empty lines are ignored.
    Returns up to `limit` hints.
    """
    path = Path(file_path)
    if not path.exists():
        logger.warning(f"File not found: {file_path}")
        print(f"⚠️ File not found: {file_path}")
        return []

    logger.info(f"Loading hints from: {file_path}")
    text = path.read_text(encoding="utf-8-sig", errors="ignore")
    lines = [
        ln.strip()
        for ln in text.splitlines()
        if ln.strip() and not ln.strip().startswith("#")
    ]
    logger.info(f"Loaded {len(lines)} hints (returning up to {limit})")
    return lines[:limit]

def load_rescue_lines(file_path: str) -> list[str]:
    """
    Load preset rescue lines from a text file (rescue_line.txt).
    Ignores empty lines and lines starting with '#'.
    """
    path = Path(file_path)
    if not path.exists():
        logger.warning(f"Rescue lines file not found: {file_path}")
        print(f"⚠️ File not found: {file_path}")
        return []

    logger.info(f"Loading rescue lines from: {file_path}")
    lines = [
        ln.strip()
        for ln in path.read_text(encoding="utf-8-sig", errors="ignore").splitlines()
        if ln.strip() and not ln.strip().startswith("#")
    ]
    logger.info(f"Loaded {len(lines)} rescue lines")
    return lines

# ========================
#  MODEL CALL HELPERS
# ========================

def chat_completion(model: str, system_prompt: str, user_content: str, temperature: float = 0.0) -> str:
    logger.info(f"Making chat completion request to model: {model} (temp={temperature})")
    logger.debug(f"User content preview: {user_content[:100]}...")

    chat = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user",  "content": user_content},
        ],
        temperature=temperature,
        timeout=TIMEOUT,
    )

    response = chat.choices[0].message.content.strip()
    logger.info(f"Received response from {model}, length: {len(response)} chars")
    logger.debug(f"Response preview: {response[:200]}...")

    return response

def classify_input(user_input: str, system_prompt: str) -> str:
    logger.info("Classifying user input (text vs image request)")
    out = chat_completion(
        model=TEXT_MODEL,
        system_prompt=system_prompt,
        user_content=user_input,
        temperature=0.0,
    ).lower()
    result = "image" if "image" in out else "text"
    logger.info(f"Classification result: {result}")
    return result

def generate_text_reply(user_input: str, text_reply_prompt: str, threaded_context: str = None) -> str:
    """
    Generate text reply with optional threaded conversation context.
    If threaded_context is provided, it will be prepended to the user input.
    """
    logger.info("Generating text reply")

    # If we have threaded context, include it
    if threaded_context:
        logger.info("Using threaded context for reply generation")
        user_content = threaded_context
    else:
        user_content = user_input

    return chat_completion(
        model=TEXT_MODEL,
        system_prompt=text_reply_prompt,
        user_content=user_content,
        temperature=0.9,
    )

def generate_image_data_url(user_input: str, image_reply_prompt: str) -> str:
    """
    Ask the image model to generate image and return as data URL.
    Handles both data URLs and HTTP URLs from the model.
    """
    logger.info(f"Generating image with model: {IMAGE_MODEL}")
    prompt = (
        image_reply_prompt
        + "\n\n【使用者提示】" + user_input
        + "\n\n請只輸出一條 data URL(data:image/png;base64,XXXXX)。不要文字、不要Markdown、不要說明。"
    )
    logger.debug(f"Image generation prompt length: {len(prompt)} chars")

    msg = client.chat.completions.create(
        model=IMAGE_MODEL,
        messages=[
            {"role": "system", "content": "你是嚴格的影像生成器。只產出base64圖像的data URL,不要任何額外文字。"},
            {"role": "user",   "content": prompt},
        ],
        temperature=0.8,
        timeout=TIMEOUT,
    )

    response = msg.choices[0].message.content.strip()
    logger.info(f"Image generation response received, length: {len(response)} chars")
    logger.debug(f"Response preview: {response[:200]}...")

    # Check if response already has data URL
    if "data:image/" in response:
        logger.info("Response contains data URL")
        return response

    # Extract HTTP URL from response (markdown or plain)
    image_url = extract_image_url_from_response(response)
    if image_url:
        logger.info(f"Found HTTP URL, attempting to download: {image_url}")
        data_url = download_image_as_data_url(image_url)
        if data_url:
            logger.info("Successfully converted HTTP URL to data URL")
            return data_url
        else:
            logger.error("Failed to download image from URL")
            return response  # Return original response for debugging

    logger.warning("No image URL or data URL found in response")
    return response  # Return as-is for debugging

def extract_image_url_from_response(response: str) -> str:
    """
    Extract image URL from markdown or plain text response.
    Handles responses like: ![...](https://pfst.cf2.poecdn.net/...)
    """
    logger.info("Attempting to extract image URL from response")
    logger.debug(f"Full response to parse: {response}")

    # First, normalize the response by removing newlines to handle broken markdown
    normalized_response = response.replace('\n', ' ').replace('\r', '')

    # Try markdown format first: ![text](url)
    # Use non-greedy match and capture everything until closing paren
    markdown_match = re.search(r'!\[.*?\]\((https://[^\)]+)\)', normalized_response, re.DOTALL)
    if markdown_match:
        url = markdown_match.group(1).strip()
        logger.info(f"Extracted URL from markdown: {url}")
        return url

    # Try plain URL format with query parameters (with or without newlines)
    # Match the entire URL including query params (?w=..&h=..)
    url_match = re.search(r'(https://pfst\.cf2\.poecdn\.net/[^\s\)\]]+)', normalized_response)
    if url_match:
        url = url_match.group(1).strip()
        logger.info(f"Extracted plain URL: {url}")
        return url

    # Try to find ANY https URL
    any_url_match = re.search(r'(https://[^\s\)\]<>"\']+)', normalized_response)
    if any_url_match:
        url = any_url_match.group(1).strip()
        # Remove trailing punctuation
        url = url.rstrip('.,;!?')
        logger.info(f"Extracted generic URL: {url}")
        return url

    logger.warning("No image URL found in response")
    return None

def download_image_as_data_url(url: str) -> str:
    """
    Download image from URL and convert to base64 data URL.
    Includes proper headers to bypass CDN restrictions.
    """
    logger.info(f"Downloading image from: {url}")
    try:
        import urllib.request

        # Create request with headers to bypass 403 Forbidden
        req = urllib.request.Request(
            url,
            headers={
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
                'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
                'Accept-Language': 'en-US,en;q=0.9',
                'Referer': 'https://poe.com/',
                'Origin': 'https://poe.com'
            }
        )

        response = urllib.request.urlopen(req, timeout=30)
        image_data = response.read()
        logger.info(f"Downloaded {len(image_data)} bytes")

        # Convert to base64
        b64 = base64.b64encode(image_data).decode('utf-8')
        data_url = f"data:image/png;base64,{b64}"
        logger.info(f"Converted to data URL, length: {len(data_url)}")
        return data_url
    except Exception as e:
        logger.error(f"Failed to download image: {e}")
        return None
def analyze_image_detailed(image_path: str) -> str:
    """
    Analyze an image with comprehensive detail extraction.
    Returns structured description of all elements in the image.
    """
    logger.info(f"Analyzing image with detailed prompt: {image_path}")

    # Read image and convert to base64
    image_data = Path(image_path).read_bytes()
    b64_image = base64.b64encode(image_data).decode('utf-8')

    # Detect image format
    ext = Path(image_path).suffix.lower()
    mime_type = "image/jpeg" if ext in [".jpg", ".jpeg"] else f"image/{ext[1:]}"

    detailed_prompt = """請詳細分析這張圖片,並提供全面的描述。請按以下結構組織你的描述:

【人物分析】
- 人數統計:共有幾位人物
- 對於每位人物,請描述:
  * 年齡範圍(例如:嬰兒、兒童、青少年、青年、中年、老年)
  * 性別
  * 族裔特徵
  * 面部特徵(例如:髮型、髮色、眼睛顏色、表情)
  * 服裝描述(顏色、風格、配飾)
  * 姿勢與動作
  * 整體氛圍/感覺
  * 如果可辨識為知名人物,請提及姓名

【動物分析】(如果有)
- 動物種類與品種
- 年齡階段(幼年/成年/老年)
- 顏色與花紋
- 姿勢與動作
- 與環境或人物的互動

【環境與場景】
- 地點類型(室內/室外、具體場所)
- 時間(白天/夜晚、季節)
- 光線與氛圍
- 背景元素描述

【物品與道具】
- 主要物品列表及其特徵
- 物品的顏色、材質、狀態
- 物品在畫面中的位置與作用

【整體構圖】
- 拍攝角度與視角
- 畫面重點與焦點
- 色調與風格
- 情緒與氛圍

請盡可能詳細且有條理地描述所有可見元素。"""

    try:
        response = client.chat.completions.create(
            model=IMAGE_MODEL,
            messages=[
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": detailed_prompt},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": f"data:{mime_type};base64,{b64_image}"
                            }
                        }
                    ]
                }
            ],
            temperature=0.3,
            timeout=TIMEOUT,
        )

        analysis = response.choices[0].message.content.strip()
        logger.info(f"Image analysis completed, length: {len(analysis)} chars")
        return analysis

    except Exception as e:
        logger.error(f"Failed to analyze image: {e}")
        return f"⚠️ 圖片分析失敗:{str(e)}"
    
def analyze_image_from_data_url(data_url: str) -> str:
    """
    Analyze image from data URL format.
    Saves temporarily, analyzes, then cleans up.
    """
    logger.info("Analyzing image from data URL")
    temp_path = "temp_analysis.png"

    try:
        # Save temporarily
        ok = save_data_url_to_file(data_url, temp_path)
        if not ok:
            logger.error("Failed to save data URL to temp file")
            return "⚠️ 無法處理上傳的圖片"

        # Analyze
        analysis = analyze_image_detailed(temp_path)

        # Cleanup
        try:
            Path(temp_path).unlink()
            logger.info("Cleaned up temp analysis file")
        except Exception as e:
            logger.warning(f"Could not delete temp file: {e}")

        return analysis

    except Exception as e:
        logger.error(f"Failed to analyze image from data URL: {e}")
        return f"⚠️ 圖片分析失敗:{str(e)}"

def generate_with_nano_banana(prompt: str) -> str:
    """
    Generate image using Nano-Banana model.
    Returns data URL or HTTP URL (which will be converted).
    """
    logger.info("Generating image with Nano-Banana model")
    logger.debug(f"Prompt length: {len(prompt)} chars")

    try:
        msg = client.chat.completions.create(
            model="Nano-Banana",  # Use Nano-Banana instead of Gemini
            messages=[
                {"role": "system", "content": "你是圖像生成助手。根據描述生成圖片。"},
                {"role": "user", "content": prompt},
            ],
            temperature=0.8,
            timeout=TIMEOUT,
        )

        response = msg.choices[0].message.content.strip()
        logger.info(f"Nano-Banana response received, length: {len(response)} chars")

        # Check if response already has data URL
        if "data:image/" in response:
            logger.info("Response contains data URL")
            return response

        # Extract and download HTTP URL
        image_url = extract_image_url_from_response(response)
        if image_url:
            logger.info(f"Found HTTP URL, downloading: {image_url}")
            data_url = download_image_as_data_url(image_url)
            if data_url:
                logger.info("Successfully converted HTTP URL to data URL")
                return data_url

        logger.warning("No valid image data in Nano-Banana response")
        return response  # Return as-is for debugging

    except Exception as e:
        logger.error(f"Nano-Banana generation failed: {e}")
        return f"⚠️ 圖片生成失敗:{str(e)}"

def generate_image_with_analysis(user_input: str, image_reply_prompt: str, uploaded_image_data: str = None, conversation_context: str = None) -> str:
    """
    Generate image from text description with optional image analysis and conversation threading.

    If uploaded_image_data provided:
      Analyze the image first, then use that analysis to generate a new image

    If conversation_context provided:
      Include previous conversation history to generate contextually relevant images

    If no uploaded_image_data:
      Use text description directly
    """
    logger.info("Starting image generation flow")

    if conversation_context:
        logger.info("Using conversation threading for image generation")
        logger.debug(f"Conversation context length: {len(conversation_context)} chars")

    if uploaded_image_data:
        # User uploaded image - try to analyze it first
        logger.info("User uploaded image - attempting to analyze image content...")

        # Try to analyze the uploaded image
        analysis_result = analyze_image_from_data_url(uploaded_image_data)

        logger.info("=" * 80)
        logger.info("IMAGE ANALYSIS RESULT:")
        logger.info("=" * 80)
        logger.info(analysis_result)
        logger.info("=" * 80)

        # Check if analysis failed
        if "無法分析" in analysis_result or "無法識別" in analysis_result or "抱歉" in analysis_result:
            logger.warning("Image analysis failed - POE vision API not working properly")
            logger.info("Will generate pumpkin image based on text description only")

            # Fallback: Use text description only, mention that image was uploaded
            # Include conversation context if available
            context_section = f"\n\n{conversation_context}" if conversation_context else ""

            enhanced_prompt = f"""{image_reply_prompt}

【使用者提示】
{user_input}

(用戶上傳了一張圖片,但圖片分析功能暫時不可用。請根據文字描述生成一張充滿南瓜元素的創意圖片。)
{context_section}

請生成一張包含南瓜元素的新圖片。

請只輸出一張圖片URL或data URL。"""
        else:
            # Analysis succeeded - use it
            logger.info("Image analysis successful - using results for generation")

            # Include conversation context if available
            context_section = f"\n\n{conversation_context}" if conversation_context else ""

            enhanced_prompt = f"""{image_reply_prompt}

【使用者提示】
{user_input}

【圖片分析結果】
以下是用戶上傳圖片的詳細分析:
{analysis_result}
{context_section}

請根據以上圖片分析結果,生成一張包含南瓜元素的新圖片。
保留原圖的主要構圖和元素,但加入南瓜相關的創意元素。
{f'請考慮對話歷史中的上下文,讓圖片與之前的對話內容相關聯。' if conversation_context else ''}

請只輸出一張圖片URL或data URL。"""

        logger.info("=" * 80)
        logger.info("FULL PROMPT SENT TO NANO-BANANA:")
        logger.info("=" * 80)
        logger.info(enhanced_prompt)
        logger.info("=" * 80)
    else:
        # No image uploaded, use text description
        logger.info("No uploaded image - generating from text description")

        # Include conversation context if available
        context_section = f"\n\n{conversation_context}" if conversation_context else ""

        enhanced_prompt = f"""{image_reply_prompt}

【使用者提示】
{user_input}
{context_section}

請生成包含南瓜元素的圖片。
{f'請考慮對話歷史中的上下文,讓圖片與之前的對話內容相關聯。' if conversation_context else ''}

請只輸出一張圖片URL或data URL。"""

        logger.info("=" * 80)
        logger.info("PROMPT SENT TO NANO-BANANA:")
        logger.info("=" * 80)
        logger.info(enhanced_prompt)
        logger.info("=" * 80)

    # Use Nano-Banana model for generation
    logger.info("Calling Nano-Banana for image generation...")
    return generate_with_nano_banana(enhanced_prompt)

def save_data_url_to_file(data_url: str, output_path: str) -> bool:
    logger.info(f"Attempting to save data URL to file: {output_path}")
    m = DATA_URL_RX.search(data_url)
    if not m:
        logger.error("Failed to extract base64 data from data URL")
        return False
    b64 = m.group(2)
    Path(output_path).write_bytes(base64.b64decode(b64))
    logger.info(f"Successfully saved image to: {output_path}")
    return True



# ========================
#  CONVERSATION THREADING
# ========================

class ConversationHistory:
    """
    Manages conversation history and threading.
    Maintains recent messages and determines when to thread related conversations.
    """
    def __init__(self, max_history: int = 5):
        self.max_history = max_history
        self.history = []  # List of (user_msg, ai_reply) tuples

    def add_turn(self, user_msg: str, ai_reply: str):
        """Add a conversation turn to history."""
        self.history.append({"user": user_msg, "ai": ai_reply})
        if len(self.history) > self.max_history:
            self.history.pop(0)
        logger.debug(f"Added turn to history. Total turns: {len(self.history)}")

    def get_history(self):
        """Get all conversation history."""
        return self.history

    def check_threading(self, new_message: str) -> tuple[bool, list]:
        """
        Check if new message relates to recent conversation.
        Returns (should_thread, related_messages).
        """
        if len(self.history) == 0:
            logger.info("No conversation history - processing message in isolation")
            return False, []

        logger.info(f"Checking threading relationships for new message (history size: {len(self.history)})")

        # Build a prompt to check relationships
        history_text = ""
        for i, turn in enumerate(self.history[-3:], 1):  # Check last 3 messages
            history_text += f"user_msg{i}: \"{turn['user']}\"\n"
            history_text += f"reply_by_llm{i}: \"{turn['ai']}\"\n\n"

        relationship_prompt = f"""分析以下對話歷史和新訊息之間的關係。

對話歷史:
{history_text}

新訊息:"{new_message}"

請判斷新訊息是否與對話歷史中的任何訊息有關聯。關聯包括:
- 繼續討論相同話題
- 追問或補充說明
- 引用或回應之前的內容
- 相關的上下文

如果有關聯,回覆 "RELATED" 並說明與哪些訊息相關。
如果無關聯,回覆 "ISOLATED"。

格式:[RELATED/ISOLATED]: 簡短說明"""

        try:
            response = chat_completion(
                model=TEXT_MODEL,
                system_prompt="你是對話關係分析專家。分析訊息之間的關聯性。",
                user_content=relationship_prompt,
                temperature=0.3,
            )

            logger.info(f"Threading analysis result: {response[:100]}")

            if "RELATED" in response.upper():
                # Get related turns (last 3 for context)
                related_turns = self.history[-3:]
                logger.info(f"Threading detected - including {len(related_turns)} previous turns")
                return True, related_turns
            else:
                logger.info("No threading relationship detected")
                return False, []

        except Exception as e:
            logger.error(f"Threading check failed: {e}")
            return False, []

    def format_threaded_context(self, related_turns: list, new_message: str) -> str:
        """
        Format conversation history for threaded context.
        """
        context = "【對話歷史】\n"
        for i, turn in enumerate(related_turns, 1):
            context += f"user_msg{i}: \"{turn['user']}\"\n"
            context += f"reply_by_llm{i}: \"{turn['ai']}\"\n\n"

        context += f"【最新訊息】\nnew_msg: \"{new_message}\"\n\n"
        context += "請主要回應最新訊息,同時考慮對話歷史提供的上下文。"

        return context

# ========================
#  GAME LOGIC
# ========================

# keep only CJK Han (Chinese characters); drop punctuation/emoji/spaces/latin/etc.
def _normalize_han_only(s: str) -> str:
    return "".join(ch for ch in s if "CJK UNIFIED IDEOGRAPH" in _ud.name(ch, ""))

def is_target_lyric(text: str) -> bool:
    """
    True if, after stripping all non-Chinese chars, the sequence matches target:
    做過幾分鐘公主搭著南瓜車亦有過愛人來接浪漫度午夜
    (Punctuation/whitespace/emoji inside the user's input are ignored.)
    """
    target = "做過幾分鐘公主搭著南瓜車亦有過愛人來接浪漫度午夜"
    normalized_input = _normalize_han_only(text)
    normalized_target = _normalize_han_only(target)
    is_match = normalized_input == normalized_target

    logger.info(f"Checking if input is target lyric: {is_match}")
    if is_match:
        logger.info("🎉 TARGET LYRIC DETECTED! User wins!")

    return is_match

def add_rescue_and_hint(base_message: str, hints_pool: list[str], rescue_lines: list[str]) -> str:
    """
    Shared function to append rescue line + hint to any message.
    Used for both text replies (when status: no) and image generation replies.
    """
    rescue_line = random.choice(rescue_lines) if rescue_lines else "快啲救我啦~我要變南瓜湯喇!"
    hint = random.choice(hints_pool) if hints_pool else "(未載入提示)"
    separator = "\n===============================================\n"

    logger.debug(f"Selected rescue line: {rescue_line}")
    logger.debug(f"Selected hint: {hint}")

    return f"{base_message}{separator}{rescue_line} 咒語提示:{hint}"

def handle_not_guessing(reply: str, hints_pool: list[str], rescue_lines: list[str]) -> str:
    """
    When {status: no}:
    - strip the tag
    - append one random rescue line + one random hint (same line)
    """
    logger.info("User is not guessing - adding rescue line and hint")
    clean_reply = STATUS_NO_RX.sub("", reply).strip()
    return add_rescue_and_hint(clean_reply, hints_pool, rescue_lines)


def handle_text_turn(user_input: str, text_reply_prompt: str, hints_pool: list[str], rescue_lines: list[str], threaded_context: str = None) -> str:
    logger.info("Processing text reply")
    reply = generate_text_reply(user_input, text_reply_prompt, threaded_context)

    if STATUS_NO_RX.search(reply):
        logger.info("Detected {status: no} in reply")
        return handle_not_guessing(reply, hints_pool, rescue_lines)

    if STATUS_YES_RX.search(reply):
        logger.info("Detected {status: yes} in reply - user is guessing!")
        reply = STATUS_YES_RX.sub("", reply).strip()

    return reply

def handle_image_turn(user_input: str, image_reply_prompt: str, output_path: str,
                      hints_pool: list[str], rescue_lines: list[str],
                      uploaded_image_data: str = None,
                      conversation_history: 'ConversationHistory' = None) -> tuple[str, str]:
    """
    Handle image generation with optional image analysis and conversation threading.
    If uploaded_image_data is provided, analyze it first before generating.
    If conversation_history is provided, check for threading relationships.

    Returns tuple of (text_response, data_url_or_none)
    """
    logger.info("Processing image generation request")

    # Check for conversation threading
    conversation_context = None
    if conversation_history:
        should_thread, related_turns = conversation_history.check_threading(user_input)
        if should_thread:
            conversation_context = conversation_history.format_threaded_context(related_turns, user_input)
            logger.info(f"[IMAGE THREADING] Using threaded context with {len(related_turns)} previous turns")
        else:
            logger.info("[IMAGE THREADING] No threading relationship detected")

    # Use new analysis-aware generation with optional conversation context
    raw = generate_image_with_analysis(user_input, image_reply_prompt, uploaded_image_data, conversation_context)

    # Try to save to file for backward compatibility (console mode)
    ok = save_data_url_to_file(raw, output_path)

    logger.info(f"Image save result: ok={ok}")

    # Extract data URL from raw response for frontend
    data_url_match = DATA_URL_RX.search(raw)
    data_url = data_url_match.group(0) if data_url_match else None

    if data_url:
        logger.info(f"Found data URL in response (length: {len(data_url)} chars)")
    else:
        logger.warning("No data URL found in response")

    # ALWAYS add rescue line + hint, regardless of success/failure
    if ok or data_url:
        base_message = f"🎃 圖像已生成"
        logger.info(f"Image successfully generated")
    else:
        base_message = f"⚠️ 圖像生成遇到問題,但我會繼續嘗試幫你!"
        logger.warning(f"Image generation failed")

    logger.info("Adding rescue line and hint to image response")

    # ALWAYS use shared function to add rescue line + hint
    final_response = add_rescue_and_hint(base_message, hints_pool, rescue_lines)

    # If failed, append debug info
    if not (ok or data_url):
        final_response += f"\n\n[Debug] Raw response: {raw[:300]}..."

    logger.info(f"Final response length: {len(final_response)} chars")
    logger.debug(f"Final response preview: {final_response[:200]}...")

    return final_response, data_url

# ========================
#  UI TEXT
# ========================

def greeting() -> str:
    return """🎃 你好,我係一個患咗南瓜強迫症嘅AI。你可以:
- 當我正常AI咁叫我做嘢  (但我可能會病發亂答一通,唔建議)
- 當我正常AI咁整圖  (但我會忍唔住用南瓜污染幅圖,唔建議)
- 畀幅圖我,我會自動用南瓜污染你幅圖 (唔建議,無謂啦)
- 嘗試用一句24個中文字組成嘅「驅瓜咒語」醫好我 (強烈建議)
──────────────────────────────────────────────
"""

def winning_text() -> str:
    return """公主!你終於出現喇!
我有南瓜強迫症,係因為畀一隻南瓜精靈上咗身!
南瓜精靈終日幻想自己係灰姑娘故事入面嘅一架南瓜車,
只有等到變成公主後嘅灰姑娘出現,佢先肯離開!
你唱出咗南瓜界經典金曲《南瓜車》嘅歌詞,超渡咗南瓜精靈,解救咗我!
而家就去(url)接收我畀你嘅小禮物啦!Happy Halloween!
"""

# ========================
#  CONSOLE LOOP
# ========================

def process_user_input(
    user_input: str,
    classify_prompt: str,
    text_reply_prompt: str,
    image_reply_prompt: str,
    hints_pool: list[str],
    rescue_lines: list[str],
    conversation_history: ConversationHistory,
    image_output_path: str = "pumpkin_output.png",
) -> tuple[str, str]:
    """
    Process user input with conversation threading support.
    Returns tuple of (response, raw_ai_reply_for_history)
    """
    logger.info(f"Processing user input: {user_input[:50]}..." if len(user_input) > 50 else f"Processing user input: {user_input}")

    if user_input.lower() in ["exit", "quit"]:
        logger.info("User requested exit")
        return "EXIT", ""

    if is_target_lyric(user_input):
        return "WIN", ""

    # Check for conversation threading
    should_thread, related_turns = conversation_history.check_threading(user_input)
    threaded_context = None

    if should_thread:
        threaded_context = conversation_history.format_threaded_context(related_turns, user_input)
        logger.info("Using threaded conversation context")

    result = classify_input(user_input, classify_prompt)

    if result == "image":
        body = handle_image_turn(user_input, image_reply_prompt, image_output_path, hints_pool, rescue_lines)
        # For image responses, store simplified reply in history
        return body, "🎃 [生成了南瓜主題圖片]"

    body = handle_text_turn(user_input, text_reply_prompt, hints_pool, rescue_lines, threaded_context)
    full_response = f"🎃 南瓜AI:{body}"

    return full_response, body

def run_console():
    logger.info("=" * 60)
    logger.info("🎃 Pumpkin AI Console Starting...")
    logger.info("=" * 60)

    PROMPT_DIR = Path(r"C:\Users\cherry.leung\OneDrive - CMRS Digital Solutions Limited\Pumpkin\New\Prompts")
    logger.info(f"Loading prompts from: {PROMPT_DIR}")

    classify_prompt, text_reply_prompt, image_reply_prompt, hints_raw = load_prompts(PROMPT_DIR)

    # file paths
    hints_path = PROMPT_DIR / "hints.txt"
    rescue_path = PROMPT_DIR / "rescue_line.txt"   # << your file name

    # load pools
    hints_pool = load_hints_simple(hints_path, limit=10)
    rescue_lines = load_rescue_lines(rescue_path)

    # Initialize conversation history manager
    conversation_history = ConversationHistory(max_history=5)
    logger.info("Conversation history manager initialized")

    logger.info(f"Initialization complete: {len(hints_pool)} hints, {len(rescue_lines)} rescue lines")
    logger.info("=" * 60)

    print(f"(Loaded {len(hints_pool)} hints, {len(rescue_lines)} rescue lines)\n")
    print(greeting())

    while True:
        user_input = input("👤 你: ").strip()

        if not user_input:
            logger.debug("Empty input received, skipping")
            continue

        logger.info(f"New user input received (length: {len(user_input)} chars)")

        outcome, ai_reply = process_user_input(
            user_input=user_input,
            classify_prompt=classify_prompt,
            text_reply_prompt=text_reply_prompt,
            image_reply_prompt=image_reply_prompt,
            hints_pool=hints_pool,
            rescue_lines=rescue_lines,
            conversation_history=conversation_history,
            image_output_path="pumpkin_output.png",
        )

        if outcome == "EXIT":
            logger.info("Session ended by user")
            print("🎃 南瓜AI:再見呀公主,下次見~")
            sys.exit(0)

        if outcome == "WIN":
            logger.info("🎉 USER WON THE GAME!")
            print(winning_text())
            sys.exit(0)

        print(outcome + "\n")
        logger.info("Response delivered to user")

        # Add this turn to conversation history
        if ai_reply:
            conversation_history.add_turn(user_input, ai_reply)
            logger.debug(f"Turn added to conversation history")

# ========================
#  ENTRYPOINT
# ========================

if __name__ == "__main__":
    run_console()