seawolf2357 commited on
Commit
78bdfe0
·
verified ·
1 Parent(s): 1f54339

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1012 -624
app.py CHANGED
@@ -1,36 +1,29 @@
 
 
 
 
 
 
 
 
1
  import os
 
 
 
 
2
  import json
3
- import copy
4
- import time
 
5
  import requests
6
- import random
7
- import logging
8
- import numpy as np
9
- import spaces
10
- from typing import Any, Dict, List, Optional, Union
11
-
12
- import torch
13
- from PIL import Image
14
- import gradio as gr
15
-
16
- from diffusers import (
17
- DiffusionPipeline,
18
- AutoencoderKL,
19
- ZImagePipeline
20
- )
21
-
22
- from huggingface_hub import (
23
- hf_hub_download,
24
- HfFileSystem,
25
- ModelCard,
26
- snapshot_download)
27
 
28
- from diffusers.utils import load_image
29
- from typing import Iterable
30
-
31
- # ============================================
32
- # Comic Style CSS
33
- # ============================================
34
  COMIC_CSS = """
35
  @import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
36
 
@@ -200,6 +193,38 @@ textarea:focus, input[type="text"]:focus {
200
  color: #1F2937 !important;
201
  }
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  label, .gr-input-label, .gr-block-label {
204
  color: #1F2937 !important;
205
  font-family: 'Comic Neue', cursive !important;
@@ -255,518 +280,847 @@ label, .gr-input-label, .gr-block-label {
255
  color: #1F2937;
256
  }
257
 
258
- /* Slider Styling */
259
- input[type="range"] {
260
- accent-color: #3B82F6;
 
 
261
  }
262
 
263
- /* Image/Gallery Container */
264
- .gr-image, .gr-gallery {
 
265
  border: 3px solid #1F2937 !important;
266
- border-radius: 8px !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  box-shadow: 4px 4px 0 #1F2937 !important;
268
  }
269
 
270
- /* Original CSS additions */
271
- #gen_btn{height: 100%}
272
- #gen_column{align-self: stretch}
273
- #title{text-align: center}
274
- #title h1{font-size: 3em; display:inline-flex; align-items:center}
275
- #title img{width: 100px; margin-right: 0.5em}
276
- #gallery .grid-wrap{height: 10vh}
277
- #lora_list{background: var(--block-background-fill);padding: 0 1em .3em; font-size: 90%}
278
- .card_internal{display: flex;height: 100px;margin-top: .5em}
279
- .card_internal img{margin-right: 1em}
280
- .styler{--form-gap-width: 0px !important}
281
- #progress{height:30px}
282
- #progress .generating{display:none}
283
- .progress-container {width: 100%;height: 30px;background-color: #f0f0f0;border-radius: 15px;overflow: hidden;margin-bottom: 20px}
284
- .progress-bar {height: 100%;background-color: #4f46e5;width: calc(var(--current) / var(--total) * 100%);transition: width 0.5s ease-in-out}
285
  """
286
 
287
- loras = [
288
- # 로컬 jimin LoRA (app.py와 같은 디렉토리에 jimin.safetensors 필요)
289
- {
290
- "image": "https://i.namu.wiki/i/VxF2jh087d4V6f9LWw1jQoLcQ_ymdhyD7XtRnq2KVYmN6DZsL4vCTrd1v-ubr8zfejyCCKvUWaBVf9JM9GqR271X6nh4e6mUbW11LaFr9QtepztFJeDZJ1VISkW5KBbebCpqv-w2Uv7RmMPwB5kacg.webp",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
- "title": "koyoon Style",
293
- "repo": "./", # 로컬 경로
294
- "weights": "koyoon.safetensors",
295
- "trigger_word": "koyoon"
296
- },
297
-
298
- {
299
- "image": "https://i.namu.wiki/i/umL8EZtn0hs-nMRYeFxIrkGrMe-R1u5c9fJE8ufrLjvXz52VcSIbG7TT9QJoL2rR7vsFww1lLrE4bwfn5uOBzfq9a90HGdNdlTLmr_KoqOchTovbVC3RDzhDbp7FI-Wq-esCu7_BYIptqethL4onBg.webp",
300
 
301
- "title": "jimin Style",
302
- "repo": "./", # 로컬 경로
303
- "weights": "jimin.safetensors",
304
- "trigger_word": "jimin"
305
- },
306
- {
307
- "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/1111111111.png",
308
- "title": "AWPortrait Z",
309
- "repo": "Shakker-Labs/AWPortrait-Z", #1
310
- "weights": "AWPortrait-Z.safetensors",
311
- "trigger_word": "Portrait"
312
- },
313
-
314
- {
315
- "image": "https://cdn-uploads.huggingface.co/production/uploads/653cd3049107029eb004f968/DLCGlF9uUnFo5zxR5uyx6.png",
316
- "title": "50s Western",
317
- "repo": "neph1/50s_western_lora_zit",
318
- "weights": "50s_western_z_100.safetensors",
319
- "trigger_word": "50s_western"
320
- },
321
-
322
- {
323
- "image": "https://huggingface.co/neph1/80s_scifi_lora_zit/resolve/main/images/ComfyUI_10288_.png",
324
- "title": "80s Scifi",
325
- "repo": "neph1/80s_scifi_lora_zit",
326
- "weights": "80s_scifi_z_80.safetensors",
327
- "trigger_word": "80s_scifi"
328
- },
329
-
330
- # --------------------------------------------------------------------------------------------------------------------------------------
331
- {
332
- "image": "https://huggingface.co/Ttio2/Z-Image-Turbo-pencil-sketch/resolve/main/images/z-image_00097_.png",
333
- "title": "Turbo Pencil",
334
- "repo": "Ttio2/Z-Image-Turbo-pencil-sketch", #0
335
- "weights": "Zimage_pencil_sketch.safetensors",
336
- "trigger_word": "pencil sketch"
337
- },
338
- {
339
- "image": "https://huggingface.co/neph1/50s_scifi_lora_zit/resolve/main/images/ComfyUI_08067_.png",
340
- "title": "50s Scifi",
341
- "repo": "neph1/50s_scifi_lora_zit",
342
- "weights": "50s_scifi_z_80.safetensors",
343
- "trigger_word": "50s_scifi"
344
- },
345
- {
346
- "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/cookie-mons.png",
347
- "title": "Yarn Art Style",
348
- "repo": "linoyts/yarn-art-style", #28
349
- "weights": "yarn-art-style_000001250.safetensors",
350
- "trigger_word": "yarn art style"
351
- },
352
- {
353
- "image": "https://huggingface.co/Quorlen/Z-Image-Turbo-Behind-Reeded-Glass-Lora/resolve/main/images/ComfyUI_00391_.png",
354
- "title": "Behind Reeded Glass",
355
- "repo": "Quorlen/Z-Image-Turbo-Behind-Reeded-Glass-Lora", #26
356
- "weights": "Z_Image_Turbo_Behind_Reeded_Glass_Lora_TAV2_000002750.safetensors",
357
- "trigger_word": "Act1vate!, Behind reeded glass"
358
- },
359
- {
360
- "image": "https://huggingface.co/ostris/z_image_turbo_childrens_drawings/resolve/main/images/1764433619736__000003000_9.jpg",
361
- "title": "Childrens Drawings",
362
- "repo": "ostris/z_image_turbo_childrens_drawings", #2
363
- "weights": "z_image_turbo_childrens_drawings.safetensors",
364
- "trigger_word": "Children Drawings"
365
- },
366
- {
367
- "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/xcxc.png",
368
- "title": "Tarot Z",
369
- "repo": "multimodalart/tarot-z-image-lora", #22
370
- "weights": "tarot-z-image_000001250.safetensors",
371
- "trigger_word": "trtcrd"
372
- },
373
- {
374
- "image": "https://huggingface.co/renderartist/Technically-Color-Z-Image-Turbo/resolve/main/images/ComfyUI_00917_.png",
375
- "title": "Technically Color Z",
376
- "repo": "renderartist/Technically-Color-Z-Image-Turbo", #3
377
- "weights": "Technically_Color_Z_Image_Turbo_v1_renderartist_2000.safetensors",
378
- "trigger_word": "t3chnic4lly"
379
- },
380
- {
381
- "image": "https://huggingface.co/SkyAsl/Tattoo-artist-Z/resolve/main/images/a%20dragon%20with%20flames.png",
382
- "title": "Tattoo-artist-Z",
383
- "repo": "SkyAsl/Tattoo-artist-Z", #31
384
- "weights": "adapter_model.safetensors",
385
- "trigger_word": "a tattoo design"
386
- },
387
- {
388
- "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/z-image_00147_.png",
389
- "title": "Turbo Ghibli",
390
- "repo": "Ttio2/Z-Image-Turbo-Ghibli-Style", #19
391
- "weights": "ghibli_zimage_finetune.safetensors",
392
- "trigger_word": "Ghibli Style"
393
- },
394
- {
395
- "image": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/images/ComfyUI_00273_.png",
396
- "title": "Pixel Art",
397
- "repo": "tarn59/pixel_art_style_lora_z_image_turbo", #4
398
- "weights": "pixel_art_style_z_image_turbo.safetensors",
399
- "trigger_word": "Pixel art style."
400
- },
401
- {
402
- "image": "https://huggingface.co/renderartist/Saturday-Morning-Z-Image-Turbo/resolve/main/images/Saturday_Morning_Z_15.png",
403
- "title": "Saturday Morning",
404
- "repo": "renderartist/Saturday-Morning-Z-Image-Turbo", #5
405
- "weights": "Saturday_Morning_Z_Image_Turbo_v1_renderartist_1250.safetensors",
406
- "trigger_word": "saturd4ym0rning"
407
- },
408
- {
409
- "image": "https://huggingface.co/AIImageStudio/ReversalFilmGravure_z_Image_turbo/resolve/main/images/2025-12-01_173047-z_image_z_image_turbo_bf16-435125750859057-euler_10_hires.png",
410
- "title": "ReversalFilmGravure",
411
- "repo": "AIImageStudio/ReversalFilmGravure_z_Image_turbo", #6
412
- "weights": "z_image_turbo_ReversalFilmGravure_v1.0.safetensors",
413
- "trigger_word": "Reversal Film Gravure, analog film photography"
414
- },
415
- {
416
- "image": "https://huggingface.co/renderartist/Coloring-Book-Z-Image-Turbo-LoRA/resolve/main/images/CBZ_00274_.png",
417
- "title": "Coloring Book Z",
418
- "repo": "renderartist/Coloring-Book-Z-Image-Turbo-LoRA", #7
419
- "weights": "Coloring_Book_Z_Image_Turbo_v1_renderartist_2000.safetensors",
420
- "trigger_word": "c0l0ringb00k"
421
- },
422
- {
423
- "image": "https://huggingface.co/damnthatai/1950s_American_Dream/resolve/main/images/ZImage_20251129163459_135x_00001_.jpg",
424
- "title": "1950s American Dream",
425
- "repo": "damnthatai/1950s_American_Dream", #8
426
- "weights": "5os4m3r1c4n4_z.safetensors",
427
- "trigger_word": "5os4m3r1c4n4, 1950s, painting, a painting of"
428
- },
429
- {
430
- "image": "https://huggingface.co/wcde/Z-Image-Turbo-DeJPEG-Lora/resolve/main/images/01.png",
431
- "title": "DeJPEG",
432
- "repo": "wcde/Z-Image-Turbo-DeJPEG-Lora", #9
433
- "weights": "dejpeg_v3.safetensors",
434
- "trigger_word": ""
435
- },
436
- {
437
- "image": "https://huggingface.co/suayptalha/Z-Image-Turbo-Realism-LoRA/resolve/main/images/n4aSpqa-YFXYo4dtcIg4W.png",
438
- "title": "DeJPEG",
439
- "repo": "suayptalha/Z-Image-Turbo-Realism-LoRA", #10
440
- "weights": "pytorch_lora_weights.safetensors",
441
- "trigger_word": "Realism"
442
- },
443
- {
444
- "image": "https://huggingface.co/renderartist/Classic-Painting-Z-Image-Turbo-LoRA/resolve/main/images/Classic_Painting_Z_00247_.png",
445
- "title": "Classic Painting Z",
446
- "repo": "renderartist/Classic-Painting-Z-Image-Turbo-LoRA", #11
447
- "weights": "Classic_Painting_Z_Image_Turbo_v1_renderartist_1750.safetensors",
448
- "trigger_word": "class1cpa1nt"
449
- },
450
- {
451
- "image": "https://huggingface.co/DK9/3D_MMORPG_style_z-image-turbo_lora/resolve/main/images/10_with_lora.png",
452
- "title": "3D MMORPG",
453
- "repo": "DK9/3D_MMORPG_style_z-image-turbo_lora", #12
454
- "weights": "lostark_v1.safetensors",
455
- "trigger_word": ""
456
- },
457
- {
458
- "image": "https://huggingface.co/Danrisi/Olympus_UltraReal_ZImage/resolve/main/images/Z-Image_01011_.png",
459
- "title": "Olympus UltraReal",
460
- "repo": "Danrisi/Olympus_UltraReal_ZImage", #13
461
- "weights": "Olympus.safetensors",
462
- "trigger_word": "digital photography, early 2000s compact camera aesthetic, amateur candid shot, digital photography, early 2000s compact camera aesthetic, amateur candid shot, direct flash lighting, hard flash shadow, specular highlights, overexposed highlights"
463
- },
464
- {
465
- "image": "https://huggingface.co/AiAF/D-ART_Z-Image-Turbo_LoRA/resolve/main/images/example_l3otpwzaz.png",
466
- "title": "D ART Z Image",
467
- "repo": "AiAF/D-ART_Z-Image-Turbo_LoRA", #14
468
- "weights": "D-ART_Z-Image-Turbo.safetensors",
469
- "trigger_word": "D-ART"
470
- },
471
- {
472
- "image": "https://huggingface.co/AlekseyCalvin/Marionette_Modernism_Z-image-Turbo_LoRA/resolve/main/bluebirdmandoll.webp",
473
- "title": "Marionette Modernism",
474
- "repo": "AlekseyCalvin/Marionette_Modernism_Z-image-Turbo_LoRA", #15
475
- "weights": "ZImageDadadoll_000003600.safetensors",
476
- "trigger_word": "DADADOLL style"
477
- },
478
- {
479
- "image": "https://huggingface.co/AlekseyCalvin/HistoricColor_Z-image-Turbo-LoRA/resolve/main/HSTZgen2.webp",
480
- "title": "Historic Color Z",
481
- "repo": "AlekseyCalvin/HistoricColor_Z-image-Turbo-LoRA", #16
482
- "weights": "ZImage1HST_000004000.safetensors",
483
- "trigger_word": "HST style"
484
- },
485
- {
486
- "image": "https://huggingface.co/tarn59/80s_air_brush_style_z_image_turbo/resolve/main/images/ComfyUI_00707_.png",
487
- "title": "80s Air Brush",
488
- "repo": "tarn59/80s_air_brush_style_z_image_turbo", #17
489
- "weights": "80s_air_brush_style_v2_z_image_turbo.safetensors",
490
- "trigger_word": "80s Air Brush style."
491
- },
492
- {
493
- "image": "https://huggingface.co/CedarC/Z-Image_360/resolve/main/images/1765505225357__000006750_6.jpg",
494
- "title": "360panorama",
495
- "repo": "CedarC/Z-Image_360", #18
496
- "weights": "Z-Image_360.safetensors",
497
- "trigger_word": "360panorama"
498
- },
499
- {
500
- "image": "https://huggingface.co/HAV0X1014/Z-Image-Turbo-KF-Bat-Eared-Fox-LoRA/resolve/main/images/ComfyUI_00132_.png",
501
- "title": "KF-Bat-Eared",
502
- "repo": "HAV0X1014/Z-Image-Turbo-KF-Bat-Eared-Fox-LoRA", #21
503
- "weights": "z-image-turbo-bat_eared_fox.safetensors",
504
- "trigger_word": "bat_eared_fox_kemono_friends"
505
- },
506
- {
507
- "image": "https://cdn-uploads.huggingface.co/production/uploads/653cd3049107029eb004f968/IHttgddXu6ZBMo7eyy8p6.png",
508
- "title": "80s Horror",
509
- "repo": "neph1/80s_horror_movies_lora_zit", #23
510
- "weights": "80s_horror_z_80.safetensors",
511
- "trigger_word": "80s_horror"
512
- },
513
- {
514
- "image": "https://huggingface.co/Quorlen/z_image_turbo_Sunbleached_Protograph_Style_Lora/resolve/main/images/ComfyUI_00024_.png",
515
- "title": "Sunbleached Protograph",
516
- "repo": "Quorlen/z_image_turbo_Sunbleached_Protograph_Style_Lora", #24
517
- "weights": "zimageturbo_Sunbleach_Photograph_Style_Lora_TAV2_000002750.safetensors",
518
- "trigger_word": "Act1vate!"
519
- },
520
- {
521
- "image": "https://huggingface.co/bunnycore/Z-Art-2.1/resolve/main/images/ComfyUI_00069_.png",
522
- "title": "Z-Art-2.1",
523
- "repo": "bunnycore/Z-Art-2.1", #25
524
- "weights": "Z-Image-Art2.1.safetensors",
525
- "trigger_word": "anime art"
526
- },
527
- {
528
- "image": "https://huggingface.co/cactusfriend/longfurby-z/resolve/main/images/1764658860954__000003000_1.jpg",
529
- "title": "Longfurby",
530
- "repo": "cactusfriend/longfurby-z", #27
531
- "weights": "longfurbyZ.safetensors",
532
- "trigger_word": ""
533
- },
534
- {
535
- "image": "https://huggingface.co/SkyAsl/Pixel-artist-Z/resolve/main/pixel-art-result.png",
536
- "title": "Pixel Art",
537
- "repo": "SkyAsl/Pixel-artist-Z", #29
538
- "weights": "adapter_model.safetensors",
539
- "trigger_word": "a pixel art character"
540
- },
541
- ]
542
-
543
- dtype = torch.bfloat16
544
- device = "cuda" if torch.cuda.is_available() else "cpu"
545
- base_model = "Tongyi-MAI/Z-Image-Turbo"
546
-
547
- print(f"Loading {base_model} pipeline...")
548
-
549
- # Initialize Pipeline
550
- pipe = ZImagePipeline.from_pretrained(
551
- base_model,
552
- torch_dtype=dtype,
553
- low_cpu_mem_usage=False,
554
- ).to(device)
555
-
556
- # ======== AoTI compilation + FA3 ========
557
- # As per reference for optimization
558
- try:
559
- print("Applying AoTI compilation and FA3...")
560
- pipe.transformer.layers._repeated_blocks = ["ZImageTransformerBlock"]
561
- spaces.aoti_blocks_load(pipe.transformer.layers, "zerogpu-aoti/Z-Image", variant="fa3")
562
- print("Optimization applied successfully.")
563
- except Exception as e:
564
- print(f"Optimization warning: {e}. Continuing with standard pipeline.")
565
-
566
- MAX_SEED = np.iinfo(np.int32).max
567
-
568
- class calculateDuration:
569
- def __init__(self, activity_name=""):
570
- self.activity_name = activity_name
571
-
572
- def __enter__(self):
573
- self.start_time = time.time()
574
- return self
575
-
576
- def __exit__(self, exc_type, exc_value, traceback):
577
- self.end_time = time.time()
578
- self.elapsed_time = self.end_time - self.start_time
579
- if self.activity_name:
580
- print(f"Elapsed time for {self.activity_name}: {self.elapsed_time:.6f} seconds")
581
  else:
582
- print(f"Elapsed time: {self.elapsed_time:.6f} seconds")
583
-
584
- def update_selection(evt: gr.SelectData, width, height):
585
- selected_lora = loras[evt.index]
586
- new_placeholder = f"Type a prompt for {selected_lora['title']}"
587
- lora_repo = selected_lora["repo"]
588
- # 로컬 LoRA 처리
589
- if lora_repo == "./":
590
- updated_text = f"### Selected: Local LoRA - {selected_lora['title']} ✅"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  else:
592
- updated_text = f"### Selected: [{lora_repo}](https://huggingface.co/{lora_repo}) ✅"
593
-
594
- # Default aspect ratio
595
- aspect = "1:1 (Instagram Square)"
596
- width = 1024
597
- height = 1024
598
-
599
- if "aspect" in selected_lora:
600
- if selected_lora["aspect"] == "portrait":
601
- aspect = "9:16 (Instagram Reels/TikTok/Shorts)"
602
- width = 768
603
- height = 1344
604
- elif selected_lora["aspect"] == "landscape":
605
- aspect = "16:9 (YouTube/Twitter/X)"
606
- width = 1344
607
- height = 768
608
-
609
- return (
610
- gr.update(placeholder=new_placeholder),
611
- updated_text,
612
- evt.index,
613
- aspect,
614
- width,
615
- height,
616
- )
617
-
618
- @spaces.GPU
619
- def run_lora(prompt, image_input, image_strength, cfg_scale, steps, selected_index, randomize_seed, seed, width, height, lora_scale, progress=gr.Progress(track_tqdm=True)):
620
- # Clean up previous LoRAs in both cases
621
- with calculateDuration("Unloading LoRA"):
622
- pipe.unload_lora_weights()
623
-
624
- # Check if a LoRA is selected
625
- if selected_index is not None and selected_index < len(loras):
626
- selected_lora = loras[selected_index]
627
- lora_path = selected_lora["repo"]
628
- trigger_word = selected_lora["trigger_word"]
629
-
630
- # Prepare Prompt with Trigger Word
631
- if(trigger_word):
632
- if "trigger_position" in selected_lora:
633
- if selected_lora["trigger_position"] == "prepend":
634
- prompt_mash = f"{trigger_word} {prompt}"
635
- else:
636
- prompt_mash = f"{prompt} {trigger_word}"
637
  else:
638
- prompt_mash = f"{trigger_word} {prompt}"
639
- else:
640
- prompt_mash = prompt
641
 
642
- # Load LoRA
643
- with calculateDuration(f"Loading LoRA weights for {selected_lora['title']}"):
644
- weight_name = selected_lora.get("weights", None)
 
645
  try:
646
- pipe.load_lora_weights(
647
- lora_path,
648
- weight_name=weight_name,
649
- adapter_name="default",
650
- low_cpu_mem_usage=True
651
- )
652
- # Set adapter scale
653
- pipe.set_adapters(["default"], adapter_weights=[lora_scale])
654
- except Exception as e:
655
- print(f"Error loading LoRA: {e}")
656
- gr.Warning("Failed to load LoRA weights. Generating with base model.")
657
- else:
658
- # Base Model Case
659
- print("No LoRA selected. Running with Base Model.")
660
- prompt_mash = prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
 
662
- with calculateDuration("Randomizing seed"):
663
- if randomize_seed:
664
- seed = random.randint(0, MAX_SEED)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
 
666
- generator = torch.Generator(device=device).manual_seed(seed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
 
668
- # Note: Z-Image-Turbo is strictly T2I in this reference implementation.
669
- # Img2Img via image_input is disabled/ignored for this pipeline update.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
 
671
- with calculateDuration("Generating image"):
672
- # For Turbo models, guidance_scale is typically 0.0
673
- forced_guidance = 0.0 # Turbo mode
 
 
 
 
 
674
 
675
- final_image = pipe(
676
- prompt=prompt_mash,
677
- height=int(height),
678
- width=int(width),
679
- num_inference_steps=int(steps),
680
- guidance_scale=forced_guidance,
681
- generator=generator,
682
- ).images[0]
683
 
684
- yield final_image, seed, gr.update(visible=False)
685
-
686
- def get_huggingface_safetensors(link):
687
- split_link = link.split("/")
688
- if(len(split_link) == 2):
689
- model_card = ModelCard.load(link)
690
- base_model = model_card.data.get("base_model")
691
- print(base_model)
692
-
693
- # Relaxed check to allow Z-Image or Flux or others, assuming user knows what they are doing
694
- # or specifically check for Z-Image-Turbo
695
- if base_model not in ["Tongyi-MAI/Z-Image-Turbo", "black-forest-labs/FLUX.1-dev"]:
696
- # Just a warning instead of error to allow experimentation
697
- print("Warning: Base model might not match.")
698
 
699
- image_path = model_card.data.get("widget", [{}])[0].get("output", {}).get("url", None)
700
- trigger_word = model_card.data.get("instance_prompt", "")
701
- image_url = f"https://huggingface.co/{link}/resolve/main/{image_path}" if image_path else None
702
- fs = HfFileSystem()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
  try:
704
- list_of_files = fs.ls(link, detail=False)
705
- for file in list_of_files:
706
- if(file.endswith(".safetensors")):
707
- safetensors_name = file.split("/")[-1]
708
- if (not image_url and file.lower().endswith((".jpg", ".jpeg", ".png", ".webp"))):
709
- image_elements = file.split("/")
710
- image_url = f"https://huggingface.co/{link}/resolve/main/{image_elements[-1]}"
711
- except Exception as e:
712
- print(e)
713
- gr.Warning(f"You didn't include a link neither a valid Hugging Face repository with a *.safetensors LoRA")
714
- raise Exception(f"You didn't include a link neither a valid Hugging Face repository with a *.safetensors LoRA")
715
- return split_link[1], link, safetensors_name, trigger_word, image_url
716
-
717
- def check_custom_model(link):
718
- if(link.startswith("https://")):
719
- if(link.startswith("https://huggingface.co") or link.startswith("https://www.huggingface.co")):
720
- link_split = link.split("huggingface.co/")
721
- return get_huggingface_safetensors(link_split[1])
722
- else:
723
- return get_huggingface_safetensors(link)
724
-
725
- def add_custom_lora(custom_lora):
726
- global loras
727
- if(custom_lora):
728
- try:
729
- title, repo, path, trigger_word, image = check_custom_model(custom_lora)
730
- print(f"Loaded custom LoRA: {repo}")
731
- card = f'''
732
- <div class="custom_lora_card">
733
- <span>Loaded custom LoRA:</span>
734
- <div class="card_internal">
735
- <img src="{image}" />
736
- <div>
737
- <h3>{title}</h3>
738
- <small>{"Using: <code><b>"+trigger_word+"</code></b> as the trigger word" if trigger_word else "No trigger word found. If there's a trigger word, include it in your prompt"}<br></small>
739
- </div>
740
- </div>
741
- </div>
742
- '''
743
- existing_item_index = next((index for (index, item) in enumerate(loras) if item['repo'] == repo), None)
744
- if(not existing_item_index):
745
- new_item = {
746
- "image": image,
747
- "title": title,
748
- "repo": repo,
749
- "weights": path,
750
- "trigger_word": trigger_word
751
- }
752
- print(new_item)
753
- existing_item_index = len(loras)
754
- loras.append(new_item)
755
 
756
- return gr.update(visible=True, value=card), gr.update(visible=True), gr.Gallery(selected_index=None), f"Custom: {path}", existing_item_index, trigger_word
757
- except Exception as e:
758
- gr.Warning(f"Invalid LoRA: either you entered an invalid link, or a non-supported LoRA")
759
- return gr.update(visible=True, value=f"Invalid LoRA: either you entered an invalid link, a non-supported LoRA"), gr.update(visible=False), gr.update(), "", None, ""
760
- else:
761
- return gr.update(visible=False), gr.update(visible=False), gr.update(), "", None, ""
762
-
763
- def remove_custom_lora():
764
- return gr.update(visible=False), gr.update(visible=False), gr.update(), "", None, ""
765
-
766
- run_lora.zerogpu = True
767
 
768
- with gr.Blocks(title="Z-IMAGE GEN/LORA", delete_cache=(60, 60)) as demo:
769
- gr.LoginButton(value="Option: HuggingFace 'Login' for extra GPU quota +", size="sm")
 
770
  # HOME Button
771
  gr.HTML("""
772
  <div class="home-button-container">
@@ -780,128 +1134,162 @@ with gr.Blocks(title="Z-IMAGE GEN/LORA", delete_cache=(60, 60)) as demo:
780
  # Header
781
  gr.HTML("""
782
  <div class="header-container">
783
- <div class="header-title">🎨 Z-IMAGE GEN/LORA 🎨</div>
784
- <div class="header-subtitle">Generate amazing images with Z-Image Turbo and various LoRA styles!</div>
785
  <div style="margin-top:12px">
786
- <span class="stats-badge"> Turbo Speed</span>
787
- <span class="stats-badge">🎭 30+ LoRAs</span>
788
- <span class="stats-badge">🖼️ High Quality</span>
789
- <span class="stats-badge">🔧 Custom LoRA</span>
 
790
  </div>
791
  </div>
792
  """)
793
 
794
- selected_index = gr.State(None)
795
- with gr.Row():
796
- with gr.Column(scale=3):
797
- prompt = gr.Textbox(label="Enter Prompt", lines=1, placeholder="✦︎ Choose the LoRA and type the prompt (LoRA = None → Base Model = Active)")
798
- with gr.Column(scale=1, elem_id="gen_column"):
799
- generate_button = gr.Button("🚀 Generate", variant="primary", elem_id="gen_btn")
800
- with gr.Row():
801
- with gr.Column():
802
- selected_info = gr.Markdown("### No LoRA Selected (Base Model)")
803
- gallery = gr.Gallery(
804
- [(item["image"], item["title"]) for item in loras],
805
- label="Z-Image LoRAs",
806
- allow_preview=False,
807
- columns=3,
808
- elem_id="gallery",
809
- )
810
- with gr.Group():
811
- custom_lora = gr.Textbox(label="Enter Custom LoRA", placeholder="Paste the LoRA path and press Enter (e.g., Shakker-Labs/AWPortrait-Z).")
812
- gr.Markdown("[Check the list of Z-Image LoRA's](https://huggingface.co/models?other=base_model:adapter:Tongyi-MAI/Z-Image-Turbo)", elem_id="lora_list")
813
- custom_lora_info = gr.HTML(visible=False)
814
- custom_lora_button = gr.Button("Remove custom LoRA", visible=False)
815
- with gr.Column():
816
- progress_bar = gr.Markdown(elem_id="progress",visible=False)
817
- result = gr.Image(label="Generated Image", format="png", height=630)
818
-
819
- # SNS Aspect Ratio Presets
820
- ASPECT_RATIOS = {
821
- "1:1 (Instagram Square)": (1024, 1024),
822
- "9:16 (Instagram Reels/TikTok/Shorts)": (768, 1344),
823
- "16:9 (YouTube/Twitter/X)": (1344, 768),
824
- "4:5 (Instagram Portrait)": (896, 1120),
825
- "5:4 (Instagram Landscape)": (1120, 896),
826
- "3:4 (Portrait Photo)": (896, 1152),
827
- "4:3 (Landscape Photo)": (1152, 896),
828
- "2:3 (Pinterest)": (832, 1248),
829
- "3:2 (Classic Photo)": (1248, 832),
830
- "21:9 (Cinematic Ultra-wide)": (1344, 576),
831
- "9:21 (Tall Banner)": (576, 1344),
832
- }
833
-
834
- def update_size(aspect_ratio):
835
- width, height = ASPECT_RATIOS.get(aspect_ratio, (1024, 1024))
836
- return width, height
837
-
838
- with gr.Row():
839
- with gr.Accordion("⚙️ Advanced Settings", open=True):
840
- with gr.Row():
841
- input_image = gr.Image(label="Input image (Ignored for Z-Image-Turbo)", type="filepath", visible=False)
842
- image_strength = gr.Slider(label="Denoise Strength", info="Ignored for Z-Image-Turbo", minimum=0.1, maximum=1.0, step=0.01, value=0.75, visible=False)
843
-
844
- gr.HTML('<div class="info-box">📐 <b>Image Size</b> - Select aspect ratio for different platforms</div>')
845
 
846
  with gr.Row():
847
- aspect_ratio = gr.Dropdown(
848
- choices=list(ASPECT_RATIOS.keys()),
849
- value="1:1 (Instagram Square)",
850
- label="📱 Aspect Ratio (SNS Presets)",
851
- info="Choose the best ratio for your target platform"
852
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
 
854
  with gr.Row():
855
- width = gr.Slider(label="Width", minimum=256, maximum=1536, step=64, value=1536)
856
- height = gr.Slider(label="Height", minimum=256, maximum=1536, step=64, value=1536)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
857
 
858
- with gr.Column():
859
- with gr.Row():
860
- cfg_scale = gr.Slider(label="CFG Scale", info="Forced to 0.0 for Turbo", minimum=0, maximum=20, step=0.5, value=0.0, interactive=False)
861
- steps = gr.Slider(label="Steps", minimum=1, maximum=50, step=1, value=25)
862
-
863
- with gr.Row():
864
- randomize_seed = gr.Checkbox(True, label="Randomize seed")
865
- seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0, randomize=True)
866
- lora_scale = gr.Slider(label="LoRA Scale", minimum=0, maximum=3, step=0.01, value=0.95)
867
-
868
- # Connect aspect ratio dropdown to width/height sliders
869
- aspect_ratio.change(
870
- fn=update_size,
871
- inputs=[aspect_ratio],
872
- outputs=[width, height]
873
- )
874
-
875
  # Footer
876
  gr.HTML("""
877
  <div class="footer-comic">
878
- <p style="font-family:'Bangers',cursive;font-size:1.5rem;letter-spacing:2px">🎨 Z-IMAGE GEN/LORA 🎨</p>
879
- <p>Powered by Z-Image Turbo + LoRA Adapters</p>
880
- <p> Fast Generation 🎭 Multiple Styles🖼️ High Quality</p>
881
  <p style="margin-top:10px"><a href="https://www.humangen.ai" target="_blank" style="color:#FACC15;text-decoration:none;font-weight:bold;">🏠 www.humangen.ai</a></p>
882
  </div>
883
  """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
 
885
- gallery.select(
886
- update_selection,
887
- inputs=[width, height],
888
- outputs=[prompt, selected_info, selected_index, aspect_ratio, width, height]
889
- )
890
- custom_lora.input(
891
- add_custom_lora,
892
- inputs=[custom_lora],
893
- outputs=[custom_lora_info, custom_lora_button, gallery, selected_info, selected_index, prompt]
894
- )
895
- custom_lora_button.click(
896
- remove_custom_lora,
897
- outputs=[custom_lora_info, custom_lora_button, gallery, selected_info, selected_index, custom_lora]
898
- )
899
- gr.on(
900
- triggers=[generate_button.click, prompt.submit],
901
- fn=run_lora,
902
- inputs=[prompt, input_image, image_strength, cfg_scale, steps, selected_index, randomize_seed, seed, width, height, lora_scale],
903
- outputs=[result, seed, progress_bar]
904
- )
905
-
906
- demo.queue()
907
- demo.launch(css=COMIC_CSS, ssr_mode=False, show_error=True)
 
1
+ """
2
+ HWP AI 어시스턴트 - Gradio 웹 앱
3
+ AI가 HWP 파일을 읽고, 보고, 말하며, 생각하고 기억합니다.
4
+ - Tab 1: LLM 채팅 (스트리밍, 파일 첨부 지원)
5
+ - Tab 2: HWP 변환기
6
+ """
7
+ import gradio as gr
8
+ import tempfile
9
  import os
10
+ import subprocess
11
+ import shutil
12
+ import sys
13
+ import re
14
  import json
15
+ import uuid
16
+ import sqlite3
17
+ import base64
18
  import requests
19
+ import zlib
20
+ import zipfile
21
+ from pathlib import Path
22
+ from datetime import datetime
23
+ from typing import Generator, List, Dict, Optional
24
+ from xml.etree import ElementTree as ET
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ # ============== Comic Style CSS ==============
 
 
 
 
 
27
  COMIC_CSS = """
28
  @import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
29
 
 
193
  color: #1F2937 !important;
194
  }
195
 
196
+ .feature-box {
197
+ background: linear-gradient(135deg, #E0F2FE 0%, #BAE6FD 100%) !important;
198
+ border: 3px solid #1F2937 !important;
199
+ border-radius: 12px !important;
200
+ padding: 20px !important;
201
+ margin: 15px 0 !important;
202
+ box-shadow: 5px 5px 0 #1F2937 !important;
203
+ }
204
+
205
+ .feature-title {
206
+ font-family: 'Bangers', cursive !important;
207
+ font-size: 1.5rem !important;
208
+ color: #1F2937 !important;
209
+ margin-bottom: 10px !important;
210
+ text-shadow: 1px 1px 0 #FFF !important;
211
+ }
212
+
213
+ .feature-item {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ padding: 8px 0;
218
+ font-family: 'Comic Neue', cursive !important;
219
+ font-weight: 700 !important;
220
+ font-size: 1rem !important;
221
+ color: #1F2937 !important;
222
+ }
223
+
224
+ .feature-icon {
225
+ font-size: 1.5rem;
226
+ }
227
+
228
  label, .gr-input-label, .gr-block-label {
229
  color: #1F2937 !important;
230
  font-family: 'Comic Neue', cursive !important;
 
280
  color: #1F2937;
281
  }
282
 
283
+ /* Chatbot Styling */
284
+ .gr-chatbot {
285
+ border: 3px solid #1F2937 !important;
286
+ border-radius: 12px !important;
287
+ box-shadow: 5px 5px 0 #1F2937 !important;
288
  }
289
 
290
+ /* Tab Styling */
291
+ .gr-tab-nav {
292
+ background: linear-gradient(135deg, #F59E0B 0%, #FACC15 100%) !important;
293
  border: 3px solid #1F2937 !important;
294
+ border-radius: 8px 8px 0 0 !important;
295
+ }
296
+
297
+ .gr-tab-nav button {
298
+ font-family: 'Bangers', cursive !important;
299
+ font-size: 1.2rem !important;
300
+ letter-spacing: 1px !important;
301
+ color: #1F2937 !important;
302
+ }
303
+
304
+ .gr-tab-nav button.selected {
305
+ background: #FFF !important;
306
+ border-bottom: 3px solid #FFF !important;
307
+ }
308
+
309
+ /* File Upload Box */
310
+ .upload-box {
311
+ border: 3px dashed #3B82F6 !important;
312
+ border-radius: 12px !important;
313
+ background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%) !important;
314
  box-shadow: 4px 4px 0 #1F2937 !important;
315
  }
316
 
317
+ .download-box {
318
+ border: 3px solid #10B981 !important;
319
+ border-radius: 12px !important;
320
+ background: linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%) !important;
321
+ box-shadow: 4px 4px 0 #1F2937 !important;
322
+ }
 
 
 
 
 
 
 
 
 
323
  """
324
 
325
+ # ============== 환경 설정 ==============
326
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
327
+ PYHWP_PATH = os.path.join(SCRIPT_DIR, 'pyhwp')
328
+ DB_PATH = os.path.join(SCRIPT_DIR, 'chat_history.db')
329
+
330
+ if os.path.exists(PYHWP_PATH):
331
+ sys.path.insert(0, PYHWP_PATH)
332
+
333
+ # ============== 모듈 임포트 ==============
334
+ try:
335
+ import olefile
336
+ OLEFILE_AVAILABLE = True
337
+ print("✅ olefile loaded")
338
+ except ImportError:
339
+ OLEFILE_AVAILABLE = False
340
+
341
+ try:
342
+ from markdownify import markdownify as md
343
+ MARKDOWNIFY_AVAILABLE = True
344
+ print("✅ markdownify loaded")
345
+ except ImportError:
346
+ MARKDOWNIFY_AVAILABLE = False
347
+
348
+ try:
349
+ import html2text
350
+ HTML2TEXT_AVAILABLE = True
351
+ print("✅ html2text loaded")
352
+ except ImportError:
353
+ HTML2TEXT_AVAILABLE = False
354
+
355
+ try:
356
+ from bs4 import BeautifulSoup
357
+ BS4_AVAILABLE = True
358
+ except ImportError:
359
+ BS4_AVAILABLE = False
360
+
361
+ try:
362
+ import PyPDF2
363
+ PYPDF2_AVAILABLE = True
364
+ print("✅ PyPDF2 loaded")
365
+ except ImportError:
366
+ PYPDF2_AVAILABLE = False
367
+
368
+ try:
369
+ import pdfplumber
370
+ PDFPLUMBER_AVAILABLE = True
371
+ print("✅ pdfplumber loaded")
372
+ except ImportError:
373
+ PDFPLUMBER_AVAILABLE = False
374
+
375
+ # ============== API 키 설정 ==============
376
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
377
+ FIREWORKS_API_KEY = os.environ.get("FIREWORKS_API_KEY", "")
378
+
379
+ # ============== SQLite 데이터베이스 ==============
380
+ def init_database():
381
+ conn = sqlite3.connect(DB_PATH)
382
+ cursor = conn.cursor()
383
+ cursor.execute('''
384
+ CREATE TABLE IF NOT EXISTS sessions (
385
+ session_id TEXT PRIMARY KEY,
386
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
387
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
388
+ title TEXT
389
+ )
390
+ ''')
391
+ cursor.execute('''
392
+ CREATE TABLE IF NOT EXISTS messages (
393
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
394
+ session_id TEXT,
395
+ role TEXT,
396
+ content TEXT,
397
+ file_info TEXT,
398
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
399
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
400
+ )
401
+ ''')
402
+ conn.commit()
403
+ conn.close()
404
+
405
+ def create_session() -> str:
406
+ session_id = str(uuid.uuid4())
407
+ conn = sqlite3.connect(DB_PATH)
408
+ cursor = conn.cursor()
409
+ cursor.execute("INSERT INTO sessions (session_id, title) VALUES (?, ?)",
410
+ (session_id, f"대화 {datetime.now().strftime('%Y-%m-%d %H:%M')}"))
411
+ conn.commit()
412
+ conn.close()
413
+ return session_id
414
+
415
+ def save_message(session_id: str, role: str, content: str, file_info: str = None):
416
+ conn = sqlite3.connect(DB_PATH)
417
+ cursor = conn.cursor()
418
+ cursor.execute("INSERT INTO messages (session_id, role, content, file_info) VALUES (?, ?, ?, ?)",
419
+ (session_id, role, content, file_info))
420
+ cursor.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", (session_id,))
421
+ conn.commit()
422
+ conn.close()
423
+
424
+ def get_session_messages(session_id: str, limit: int = 20) -> List[Dict]:
425
+ conn = sqlite3.connect(DB_PATH)
426
+ cursor = conn.cursor()
427
+ cursor.execute("SELECT role, content, file_info, created_at FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
428
+ (session_id, limit))
429
+ rows = cursor.fetchall()
430
+ conn.close()
431
+ return [{"role": r[0], "content": r[1], "file_info": r[2], "created_at": r[3]} for r in reversed(rows)]
432
+
433
+ def get_all_sessions() -> List[Dict]:
434
+ conn = sqlite3.connect(DB_PATH)
435
+ cursor = conn.cursor()
436
+ cursor.execute("SELECT session_id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT 50")
437
+ rows = cursor.fetchall()
438
+ conn.close()
439
+ return [{"session_id": r[0], "title": r[1], "created_at": r[2], "updated_at": r[3]} for r in rows]
440
+
441
+ def update_session_title(session_id: str, title: str):
442
+ conn = sqlite3.connect(DB_PATH)
443
+ cursor = conn.cursor()
444
+ cursor.execute("UPDATE sessions SET title = ? WHERE session_id = ?", (title, session_id))
445
+ conn.commit()
446
+ conn.close()
447
+
448
+ init_database()
449
+
450
+ # ============== 파일 유틸리티 ==============
451
+ def extract_text_from_pdf(file_path: str) -> str:
452
+ text_parts = []
453
+ if PDFPLUMBER_AVAILABLE:
454
+ try:
455
+ with pdfplumber.open(file_path) as pdf:
456
+ for page in pdf.pages:
457
+ text = page.extract_text()
458
+ if text:
459
+ text_parts.append(text)
460
+ if text_parts:
461
+ return "\n\n".join(text_parts)
462
+ except Exception as e:
463
+ print(f"pdfplumber error: {e}")
464
+
465
+ if PYPDF2_AVAILABLE:
466
+ try:
467
+ with open(file_path, 'rb') as f:
468
+ reader = PyPDF2.PdfReader(f)
469
+ for page in reader.pages:
470
+ text = page.extract_text()
471
+ if text:
472
+ text_parts.append(text)
473
+ if text_parts:
474
+ return "\n\n".join(text_parts)
475
+ except Exception as e:
476
+ print(f"PyPDF2 error: {e}")
477
+ return None
478
+
479
+ def extract_text_from_txt(file_path: str) -> str:
480
+ for encoding in ['utf-8', 'euc-kr', 'cp949', 'utf-16', 'latin-1']:
481
+ try:
482
+ with open(file_path, 'r', encoding=encoding) as f:
483
+ return f.read()
484
+ except:
485
+ continue
486
+ return None
487
+
488
+ def image_to_base64(file_path: str) -> str:
489
+ with open(file_path, 'rb') as f:
490
+ return base64.b64encode(f.read()).decode('utf-8')
491
+
492
+ def get_image_mime_type(file_path: str) -> str:
493
+ ext = Path(file_path).suffix.lower()
494
+ return {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
495
+ '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp'}.get(ext, 'image/jpeg')
496
+
497
+ def is_image_file(fp: str) -> bool:
498
+ return Path(fp).suffix.lower() in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']
499
+
500
+ def is_hwp_file(fp: str) -> bool:
501
+ return Path(fp).suffix.lower() == '.hwp'
502
+
503
+ def is_hwpx_file(fp: str) -> bool:
504
+ return Path(fp).suffix.lower() == '.hwpx'
505
+
506
+ def is_pdf_file(fp: str) -> bool:
507
+ return Path(fp).suffix.lower() == '.pdf'
508
+
509
+ def is_text_file(fp: str) -> bool:
510
+ return Path(fp).suffix.lower() in ['.txt', '.md', '.json', '.csv', '.xml', '.html', '.css', '.js', '.py']
511
+
512
+ # ============== HWPX 텍스트 추출 ==============
513
+ def extract_text_from_hwpx(file_path: str) -> tuple:
514
+ try:
515
+ text_parts = []
516
+ with zipfile.ZipFile(file_path, 'r') as zf:
517
+ file_list = zf.namelist()
518
+ section_files = sorted([f for f in file_list if f.startswith('Contents/section') and f.endswith('.xml')])
519
+ if not section_files:
520
+ section_files = sorted([f for f in file_list if 'section' in f.lower() and f.endswith('.xml')])
521
+
522
+ for section_file in section_files:
523
+ try:
524
+ with zf.open(section_file) as sf:
525
+ content = sf.read()
526
+ content_str = content.decode('utf-8')
527
+ content_str = re.sub(r'\sxmlns[^"]*"[^"]*"', '', content_str)
528
+ content_str = re.sub(r'<[a-zA-Z]+:', '<', content_str)
529
+ content_str = re.sub(r'</[a-zA-Z]+:', '</', content_str)
530
+
531
+ try:
532
+ root = ET.fromstring(content_str)
533
+ texts = []
534
+ for elem in root.iter():
535
+ if elem.tag.endswith('t') or elem.tag == 't':
536
+ if elem.text:
537
+ texts.append(elem.text)
538
+ elif elem.text and elem.text.strip():
539
+ if any(x in elem.tag.lower() for x in ['text', 'run', 'para', 'char']):
540
+ texts.append(elem.text.strip())
541
+ if texts:
542
+ text_parts.append(' '.join(texts))
543
+ except ET.ParseError:
544
+ text_matches = re.findall(r'>([^<]+)<', content.decode('utf-8', errors='ignore'))
545
+ clean_texts = [t.strip() for t in text_matches if t.strip() and len(t.strip()) > 1]
546
+ if clean_texts:
547
+ text_parts.append(' '.join(clean_texts))
548
+ except:
549
+ continue
550
+
551
+ if text_parts:
552
+ result = '\n\n'.join(text_parts)
553
+ result = re.sub(r'\s+', ' ', result)
554
+ result = re.sub(r'\n{3,}', '\n\n', result)
555
+ return result.strip(), None
556
+ return None, "HWPX에서 텍스트를 찾을 수 없습니다"
557
+ except zipfile.BadZipFile:
558
+ return None, "유효하지 않은 HWPX 파일"
559
+ except Exception as e:
560
+ return None, f"HWPX 처리 오류: {str(e)}"
561
+
562
+ # ============== HWP 텍스트 추출 ==============
563
+ def extract_text_with_hwp5txt(file_path: str) -> tuple:
564
+ try:
565
+ result = subprocess.run(['hwp5txt', file_path], capture_output=True, timeout=60)
566
+ if result.returncode == 0 and result.stdout:
567
+ for enc in ['utf-8', 'cp949', 'euc-kr']:
568
+ try:
569
+ text = result.stdout.decode(enc)
570
+ if text.strip() and len(text.strip()) > 10:
571
+ return text.strip(), None
572
+ except:
573
+ continue
574
+ except FileNotFoundError:
575
+ pass
576
+ except Exception as e:
577
+ print(f"hwp5txt error: {e}")
578
+
579
+ try:
580
+ code = f'''
581
+ import sys
582
+ sys.path.insert(0, "{PYHWP_PATH}")
583
+ from hwp5.filestructure import Hwp5File
584
+ from hwp5.hwp5txt import extract_text
585
+ hwp = Hwp5File("{file_path}")
586
+ for idx in hwp.bodytext.sections():
587
+ section = hwp.bodytext.section(idx)
588
+ for para in extract_text(section):
589
+ if para.strip():
590
+ print(para.strip())
591
+ hwp.close()
592
+ '''
593
+ result = subprocess.run([sys.executable, '-c', code], capture_output=True, timeout=60)
594
+ if result.returncode == 0 and result.stdout:
595
+ for enc in ['utf-8', 'cp949', 'euc-kr']:
596
+ try:
597
+ text = result.stdout.decode(enc)
598
+ if text.strip() and len(text.strip()) > 10:
599
+ return text.strip(), None
600
+ except:
601
+ continue
602
+ except Exception as e:
603
+ print(f"hwp5txt subprocess error: {e}")
604
+
605
+ return None, "hwp5txt 실패"
606
+
607
+ def extract_text_with_olefile(file_path: str) -> tuple:
608
+ if not OLEFILE_AVAILABLE:
609
+ return None, "olefile 모듈 없음"
610
+
611
+ try:
612
+ ole = olefile.OleFileIO(file_path)
613
+ if not ole.exists('FileHeader'):
614
+ ole.close()
615
+ return None, "HWP 파일 헤더 없음"
616
 
617
+ header_data = ole.openstream('FileHeader').read()
618
+ is_compressed = (header_data[36] & 1) == 1 if len(header_data) > 36 else True
 
 
 
 
 
 
619
 
620
+ all_texts = []
621
+ for entry in ole.listdir():
622
+ entry_path = '/'.join(entry)
623
+ if entry_path.startswith('BodyText/Section'):
624
+ try:
625
+ stream_data = ole.openstream(entry).read()
626
+ if is_compressed:
627
+ try:
628
+ stream_data = zlib.decompress(stream_data, -15)
629
+ except:
630
+ try:
631
+ stream_data = zlib.decompress(stream_data)
632
+ except:
633
+ pass
634
+
635
+ section_text = extract_hwp_section_text(stream_data)
636
+ if section_text:
637
+ all_texts.append(section_text)
638
+ except:
639
+ continue
640
+
641
+ ole.close()
642
+ if all_texts:
643
+ return '\n\n'.join(all_texts).strip(), None
644
+ return None, "텍스트를 찾을 수 없습니다"
645
+ except Exception as e:
646
+ return None, f"olefile 오류: {str(e)}"
647
+
648
+ def extract_hwp_section_text(data: bytes) -> str:
649
+ texts = []
650
+ pos = 0
651
+ while pos < len(data) - 4:
652
+ try:
653
+ header = int.from_bytes(data[pos:pos+4], 'little')
654
+ tag_id = header & 0x3FF
655
+ size = (header >> 20) & 0xFFF
656
+ pos += 4
657
+ if size == 0xFFF:
658
+ if pos + 4 > len(data):
659
+ break
660
+ size = int.from_bytes(data[pos:pos+4], 'little')
661
+ pos += 4
662
+ if pos + size > len(data):
663
+ break
664
+ record_data = data[pos:pos+size]
665
+ pos += size
666
+ if tag_id == 67 and size > 0:
667
+ text = decode_para_text(record_data)
668
+ if text:
669
+ texts.append(text)
670
+ except:
671
+ pos += 1
672
+ continue
673
+ return '\n'.join(texts) if texts else None
674
+
675
+ def decode_para_text(data: bytes) -> str:
676
+ result = []
677
+ i = 0
678
+ while i < len(data) - 1:
679
+ code = int.from_bytes(data[i:i+2], 'little')
680
+ if code == 0:
681
+ pass
682
+ elif code == 1:
683
+ i += 14
684
+ elif code == 2:
685
+ i += 14
686
+ elif code == 3:
687
+ i += 14
688
+ elif code == 4:
689
+ pass
690
+ elif code == 9:
691
+ result.append('\t')
692
+ elif code == 10:
693
+ result.append('\n')
694
+ elif code == 13:
695
+ result.append('\n')
696
+ elif code == 24:
697
+ result.append('-')
698
+ elif code == 30 or code == 31:
699
+ result.append(' ')
700
+ elif code < 32:
701
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  else:
703
+ try:
704
+ char = chr(code)
705
+ if char.isprintable() or char in '\n\t ':
706
+ result.append(char)
707
+ except:
708
+ pass
709
+ i += 2
710
+ text = ''.join(result).strip()
711
+ text = re.sub(r'[ \t]+', ' ', text)
712
+ text = re.sub(r'\n{3,}', '\n\n', text)
713
+ return text if len(text) > 2 else None
714
+
715
+ def extract_text_from_hwp(file_path: str) -> tuple:
716
+ print(f"\n📖 [HWP 읽기] {os.path.basename(file_path)}")
717
+ text, error = extract_text_with_hwp5txt(file_path)
718
+ if text and len(text.strip()) > 20:
719
+ print(f" ✅ 성공: {len(text)} 글자")
720
+ return text, None
721
+ text, error = extract_text_with_olefile(file_path)
722
+ if text and len(text.strip()) > 20:
723
+ print(f" ✅ 성공: {len(text)} 글자")
724
+ return text, None
725
+ print(f" ❌ 실패: {error}")
726
+ return None, "모든 추출 방법 실패"
727
+
728
+ def extract_text_from_hwp_or_hwpx(file_path: str) -> tuple:
729
+ if is_hwpx_file(file_path):
730
+ print(f"\n📖 [HWPX 읽기] {os.path.basename(file_path)}")
731
+ return extract_text_from_hwpx(file_path)
732
  else:
733
+ return extract_text_from_hwp(file_path)
734
+
735
+ # ============== HWP 변환 함수들 ==============
736
+ def check_hwp_version(file_path):
737
+ try:
738
+ with open(file_path, 'rb') as f:
739
+ header = f.read(32)
740
+ if b'HWP Document File' in header:
741
+ return "HWP v5", True
742
+ elif header[:4] == b'\xd0\xcf\x11\xe0':
743
+ return "HWP v5 (OLE)", True
744
+ elif header[:4] == b'PK\x03\x04':
745
+ return "HWPX", True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  else:
747
+ return "Unknown", False
748
+ except Exception as e:
749
+ return f"Error: {e}", False
750
 
751
+ def convert_to_html_subprocess(input_path, output_dir):
752
+ output_path = os.path.join(output_dir, "output.html")
753
+ try:
754
+ for cmd in [['hwp5html', '--output', output_path, input_path]]:
755
  try:
756
+ result = subprocess.run(cmd, capture_output=True, timeout=120)
757
+ if result.returncode == 0:
758
+ if os.path.exists(output_path):
759
+ return output_path, None
760
+ for item in os.listdir(output_dir):
761
+ item_path = os.path.join(output_dir, item)
762
+ if item.lower().endswith(('.html', '.htm')):
763
+ return item_path, None
764
+ if os.path.isdir(item_path):
765
+ return item_path, None
766
+ except:
767
+ continue
768
+ except Exception as e:
769
+ print(f"HTML 변환 오류: {e}")
770
+ return None, "HTML 변환 실패"
771
+
772
+ def html_to_markdown(html_content):
773
+ if MARKDOWNIFY_AVAILABLE:
774
+ try:
775
+ return md(html_content, heading_style="ATX", bullets="-"), None
776
+ except:
777
+ pass
778
+ if HTML2TEXT_AVAILABLE:
779
+ try:
780
+ h = html2text.HTML2Text()
781
+ h.body_width = 0
782
+ return h.handle(html_content), None
783
+ except:
784
+ pass
785
+ if BS4_AVAILABLE:
786
+ try:
787
+ soup = BeautifulSoup(html_content, 'html.parser')
788
+ return soup.get_text(separator='\n'), None
789
+ except:
790
+ pass
791
+ return None, "Markdown 변환 실패"
792
+
793
+ def convert_hwp_to_markdown(input_path: str) -> tuple:
794
+ text, error = extract_text_from_hwp_or_hwpx(input_path)
795
+ if text:
796
+ return text, None
797
+ return None, error
798
+
799
+ # ============== LLM API ==============
800
+ def call_groq_api_stream(messages: List[Dict], api_key: str) -> Generator[str, None, None]:
801
+ if not api_key:
802
+ yield "❌ Groq API 키가 설정되지 않았습니다."
803
+ return
804
+ try:
805
+ response = requests.post(
806
+ "https://api.groq.com/openai/v1/chat/completions",
807
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
808
+ json={
809
+ "model": "meta-llama/llama-4-scout-17b-16e-instruct",
810
+ "messages": messages,
811
+ "temperature": 0.7,
812
+ "max_tokens": 8192,
813
+ "stream": True
814
+ },
815
+ stream=True
816
+ )
817
+ if response.status_code != 200:
818
+ yield f"❌ Groq API 오류: {response.status_code}"
819
+ return
820
+ for line in response.iter_lines():
821
+ if line:
822
+ line = line.decode('utf-8')
823
+ if line.startswith('data: ') and line[6:] != '[DONE]':
824
+ try:
825
+ data = json.loads(line[6:])
826
+ content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
827
+ if content:
828
+ yield content
829
+ except:
830
+ continue
831
+ except Exception as e:
832
+ yield f"❌ API 오류: {str(e)}"
833
+
834
+ def call_fireworks_api_stream(messages: List[Dict], image_base64: str, mime_type: str, api_key: str) -> Generator[str, None, None]:
835
+ if not api_key:
836
+ yield "❌ Fireworks API 키가 설정되지 않았습니다."
837
+ return
838
+ try:
839
+ formatted_messages = [{"role": m["role"], "content": m["content"]} for m in messages[:-1]]
840
+ formatted_messages.append({
841
+ "role": messages[-1]["role"],
842
+ "content": [
843
+ {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}},
844
+ {"type": "text", "text": messages[-1]["content"]}
845
+ ]
846
+ })
847
+ response = requests.post(
848
+ "https://api.fireworks.ai/inference/v1/chat/completions",
849
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
850
+ json={
851
+ "model": "accounts/fireworks/models/qwen3-vl-235b-a22b-thinking",
852
+ "max_tokens": 4096,
853
+ "temperature": 0.6,
854
+ "messages": formatted_messages,
855
+ "stream": True
856
+ },
857
+ stream=True
858
+ )
859
+ if response.status_code != 200:
860
+ yield f"❌ Fireworks API 오류: {response.status_code}"
861
+ return
862
+ for line in response.iter_lines():
863
+ if line:
864
+ line = line.decode('utf-8')
865
+ if line.startswith('data: ') and line[6:] != '[DONE]':
866
+ try:
867
+ data = json.loads(line[6:])
868
+ content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
869
+ if content:
870
+ yield content
871
+ except:
872
+ continue
873
+ except Exception as e:
874
+ yield f"❌ API 오류: {str(e)}"
875
+
876
+ # ============== 채팅 처리 ==============
877
+ def process_file(file_path: str) -> tuple:
878
+ if not file_path:
879
+ return None, None, None
880
+ filename = os.path.basename(file_path)
881
+
882
+ if is_image_file(file_path):
883
+ return "image", image_to_base64(file_path), get_image_mime_type(file_path)
884
+
885
+ if is_hwp_file(file_path) or is_hwpx_file(file_path):
886
+ text, error = extract_text_from_hwp_or_hwpx(file_path)
887
+ if text and len(text.strip()) > 20:
888
+ return "text", f"[📄 한글 문서: {filename}]\n\n{text}", None
889
+ return "error", f"한글 문서 추출 실패: {error}", None
890
+
891
+ if is_pdf_file(file_path):
892
+ text = extract_text_from_pdf(file_path)
893
+ if text:
894
+ return "text", f"[📑 PDF 문서: {filename}]\n\n{text}", None
895
+ return "error", "PDF 추출 실패", None
896
+
897
+ if is_text_file(file_path):
898
+ text = extract_text_from_txt(file_path)
899
+ if text:
900
+ return "text", f"[📝 텍스트 파일: {filename}]\n\n{text}", None
901
+ return "error", "텍스트 읽기 실패", None
902
+
903
+ return "unsupported", f"지원하지 않는 형식: {filename}", None
904
+
905
+ def chat_response(message: str, history: List[Dict], file: Optional[str],
906
+ session_id: str, groq_key: str, fireworks_key: str) -> Generator[tuple, None, None]:
907
+ if history is None:
908
+ history = []
909
+ if not message.strip() and not file:
910
+ yield history, session_id
911
+ return
912
+ if not session_id:
913
+ session_id = create_session()
914
+
915
+ file_type, file_content, file_mime = None, None, None
916
+ file_info = None
917
+
918
+ if file:
919
+ file_type, file_content, file_mime = process_file(file)
920
+ file_info = json.dumps({"type": file_type, "filename": os.path.basename(file)})
921
 
922
+ if file_type == "error":
923
+ history = history + [
924
+ {"role": "user", "content": message or "파일 업로드"},
925
+ {"role": "assistant", "content": f"❌ {file_content}"}
926
+ ]
927
+ yield history, session_id
928
+ return
929
+ elif file_type == "unsupported":
930
+ history = history + [
931
+ {"role": "user", "content": message or "파일 업로드"},
932
+ {"role": "assistant", "content": f"⚠️ {file_content}"}
933
+ ]
934
+ yield history, session_id
935
+ return
936
+
937
+ user_msg = message
938
+ if file:
939
+ filename = os.path.basename(file)
940
+ user_msg = f"📎 {filename}\n\n{message}" if message else f"📎 {filename}"
941
+
942
+ history = history + [{"role": "user", "content": user_msg}, {"role": "assistant", "content": ""}]
943
+ yield history, session_id
944
+
945
+ db_messages = get_session_messages(session_id, limit=10)
946
+ api_messages = [{
947
+ "role": "system",
948
+ "content": "당신은 도움이 되는 AI 어시스턴트입니다. 한국어로 자연스럽게 대화하며, 파일이 첨부되면 내용을 상세히 분석하여 답변합니다. 문서의 핵심 내용을 파악하고, 사용자의 질문에 정확하게 답변하세요."
949
+ }]
950
+
951
+ for m in db_messages:
952
+ api_messages.append({"role": m["role"], "content": m["content"]})
953
+
954
+ current_content = message or ""
955
+ if file_type == "text" and file_content:
956
+ current_content = f"{file_content}\n\n사용자 질문: {message}" if message else f"{file_content}\n\n위 문서 내용을 요약해주세요."
957
+
958
+ api_messages.append({"role": "user", "content": current_content})
959
 
960
+ full_response = ""
961
+ if file_type == "image":
962
+ for chunk in call_fireworks_api_stream(api_messages, file_content, file_mime, fireworks_key):
963
+ full_response += chunk
964
+ history[-1] = {"role": "assistant", "content": full_response}
965
+ yield history, session_id
966
+ else:
967
+ for chunk in call_groq_api_stream(api_messages, groq_key):
968
+ full_response += chunk
969
+ history[-1] = {"role": "assistant", "content": full_response}
970
+ yield history, session_id
971
+
972
+ save_message(session_id, "user", current_content, file_info)
973
+ save_message(session_id, "assistant", full_response)
974
+
975
+ if len(db_messages) == 0 and message:
976
+ update_session_title(session_id, message[:50])
977
+
978
+ def new_chat():
979
+ return [], create_session(), None
980
+
981
+ def load_session(session_id: str) -> tuple:
982
+ if not session_id:
983
+ return [], ""
984
+ messages = get_session_messages(session_id, limit=50)
985
+ return [{"role": m["role"], "content": m["content"]} for m in messages], session_id
986
 
987
+ # ============== HWP 변환기 ==============
988
+ def convert_to_odt_subprocess(input_path, output_dir):
989
+ output_path = os.path.join(output_dir, "output.odt")
990
+ try:
991
+ result = subprocess.run(['hwp5odt', '--output', output_path, input_path], capture_output=True, timeout=120)
992
+ if result.returncode == 0 and os.path.exists(output_path):
993
+ return output_path, None
994
+ except:
995
+ pass
996
+ return None, "ODT 변환 실패"
997
+
998
+ def convert_to_xml_subprocess(input_path, output_dir):
999
+ output_path = os.path.join(output_dir, "output.xml")
1000
+ try:
1001
+ result = subprocess.run(['hwp5xml', input_path], capture_output=True, timeout=120)
1002
+ if result.returncode == 0 and result.stdout:
1003
+ with open(output_path, 'wb') as f:
1004
+ f.write(result.stdout)
1005
+ return output_path, None
1006
+ except:
1007
+ pass
1008
+ return None, "XML 변환 실패"
1009
+
1010
+ def convert_hwp(file, output_format, progress=gr.Progress()):
1011
+ if not file:
1012
+ return None, "❌ 파일을 업로드해주세요.", ""
1013
+
1014
+ input_file = file.name if hasattr(file, 'name') else str(file)
1015
+ ext_lower = Path(input_file).suffix.lower()
1016
+
1017
+ if ext_lower not in ['.hwp', '.hwpx']:
1018
+ return None, "❌ HWP 또는 HWPX 파일만 지원됩니다.", ""
1019
+
1020
+ progress(0.1, desc="📖 파일 읽는 중...")
1021
+ version, is_valid = check_hwp_version(input_file)
1022
+ if not is_valid:
1023
+ return None, f"❌ 지원하지 않는 파일: {version}", ""
1024
 
1025
+ tmp_dir = tempfile.mkdtemp()
1026
+
1027
+ try:
1028
+ input_filename = os.path.basename(input_file)
1029
+ input_path = os.path.join(tmp_dir, input_filename)
1030
+ shutil.copy(input_file, input_path)
1031
+
1032
+ progress(0.3, desc=f"🔄 {output_format}로 변환 중...")
1033
 
1034
+ output_path, error, ext = None, None, ""
 
 
 
 
 
 
 
1035
 
1036
+ if output_format == "HTML":
1037
+ if ext_lower == '.hwpx':
1038
+ return None, "❌ HWPX는 HTML 변환을 지원하지 않습니다.", ""
1039
+ output_path, error = convert_to_html_subprocess(input_path, tmp_dir)
1040
+ ext = ".html"
1041
+ if output_path and os.path.isdir(output_path):
1042
+ zip_path = shutil.make_archive(os.path.join(tmp_dir, "html"), 'zip', output_path)
1043
+ output_path, ext = zip_path, ".zip"
 
 
 
 
 
 
1044
 
1045
+ elif output_format == "ODT (OpenDocument)":
1046
+ if ext_lower == '.hwpx':
1047
+ return None, " HWPX는 ODT 변환을 지원하지 않습니다.", ""
1048
+ output_path, error = convert_to_odt_subprocess(input_path, tmp_dir)
1049
+ ext = ".odt"
1050
+
1051
+ elif output_format == "TXT (텍스트)":
1052
+ text, error = extract_text_from_hwp_or_hwpx(input_path)
1053
+ if text:
1054
+ output_path = os.path.join(tmp_dir, "output.txt")
1055
+ with open(output_path, 'w', encoding='utf-8') as f:
1056
+ f.write(text)
1057
+ ext = ".txt"
1058
+
1059
+ elif output_format == "Markdown":
1060
+ text, error = convert_hwp_to_markdown(input_path)
1061
+ if text:
1062
+ output_path = os.path.join(tmp_dir, "output.md")
1063
+ with open(output_path, 'w', encoding='utf-8') as f:
1064
+ f.write(text)
1065
+ ext = ".md"
1066
+
1067
+ elif output_format == "XML":
1068
+ if ext_lower == '.hwpx':
1069
+ try:
1070
+ with zipfile.ZipFile(input_path, 'r') as zf:
1071
+ xml_contents = []
1072
+ for name in zf.namelist():
1073
+ if name.endswith('.xml'):
1074
+ with zf.open(name) as f:
1075
+ xml_contents.append(f"<!-- {name} -->\n{f.read().decode('utf-8', errors='ignore')}")
1076
+ output_path = os.path.join(tmp_dir, "output.xml")
1077
+ with open(output_path, 'w', encoding='utf-8') as f:
1078
+ f.write('\n\n'.join(xml_contents))
1079
+ except Exception as e:
1080
+ error = f"HWPX XML 추출 실패: {e}"
1081
+ else:
1082
+ output_path, error = convert_to_xml_subprocess(input_path, tmp_dir)
1083
+ ext = ".xml"
1084
+
1085
+ if not output_path:
1086
+ return None, f"❌ {error or '변환 실패'}", ""
1087
+
1088
+ if not os.path.exists(output_path):
1089
+ return None, "❌ 변환된 파일을 찾을 수 없습니다.", ""
1090
+
1091
+ progress(0.8, desc="✅ 완료 중...")
1092
+
1093
+ base_name = Path(input_filename).stem
1094
+ final_output = os.path.join(tmp_dir, f"{base_name}{ext}")
1095
+ if output_path != final_output:
1096
+ shutil.copy2(output_path, final_output)
1097
+
1098
+ file_size = os.path.getsize(final_output)
1099
+ size_str = f"{file_size/1024:.1f} KB" if file_size > 1024 else f"{file_size} bytes"
1100
+
1101
+ preview = ""
1102
+ if ext in ['.txt', '.md', '.xml']:
1103
  try:
1104
+ with open(final_output, 'r', encoding='utf-8', errors='ignore') as f:
1105
+ preview = f.read(5000)
1106
+ if len(preview) >= 5000:
1107
+ preview += "\n\n... (생략)"
1108
+ except:
1109
+ pass
1110
+ elif ext == '.zip':
1111
+ preview = "📦 HTML이 ZIP으로 압축되었습니다."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
 
1113
+ progress(1.0, desc="🎉 완료!")
1114
+ return final_output, f"✅ 변환 완료: {base_name}{ext} ({size_str})", preview
1115
+
1116
+ except Exception as e:
1117
+ import traceback
1118
+ traceback.print_exc()
1119
+ return None, f"❌ 오류: {str(e)}", ""
 
 
 
 
1120
 
1121
+ # ============== Gradio UI ==============
1122
+ with gr.Blocks(title="HWP AI 어시스턴트", css=COMIC_CSS, delete_cache=(60, 60)) as demo:
1123
+
1124
  # HOME Button
1125
  gr.HTML("""
1126
  <div class="home-button-container">
 
1134
  # Header
1135
  gr.HTML("""
1136
  <div class="header-container">
1137
+ <div class="header-title">📄 HWP AI 어시스턴트 🤖</div>
1138
+ <div class="header-subtitle">AI가 HWP 파일을 읽고, 보고, 말하며, 생각하고 기억합니다!</div>
1139
  <div style="margin-top:12px">
1140
+ <span class="stats-badge">📖 읽기 READ</span>
1141
+ <span class="stats-badge">👁️ 보기 SEE</span>
1142
+ <span class="stats-badge">💬 말하기 SPEAK</span>
1143
+ <span class="stats-badge">🧠 생각 THINK</span>
1144
+ <span class="stats-badge">💾 기억 MEMORY</span>
1145
  </div>
1146
  </div>
1147
  """)
1148
 
1149
+ session_state = gr.State("")
1150
+
1151
+ with gr.Tabs():
1152
+ # Tab 1: AI 채팅
1153
+ with gr.Tab("💬 AI 채팅"):
1154
+ # Feature Box
1155
+ gr.HTML("""
1156
+ <div class="feature-box">
1157
+ <div class="feature-title">🎯 AI 문서 분석 기능</div>
1158
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
1159
+ <div class="feature-item"><span class="feature-icon">📖</span> <b>읽기</b> - HWP/HWPX/PDF 자동 텍스트 추출</div>
1160
+ <div class="feature-item"><span class="feature-icon">👁️</span> <b>보기</b> - 이미지 분석 및 이해</div>
1161
+ <div class="feature-item"><span class="feature-icon">💬</span> <b>말하기</b> - 자연스러운 한국어 대화</div>
1162
+ <div class="feature-item"><span class="feature-icon">🧠</span> <b>생각</b> - 문서 내용 분석 및 요약</div>
1163
+ <div class="feature-item"><span class="feature-icon">💾</span> <b>기억</b> - 대화 기록 자동 저장</div>
1164
+ </div>
1165
+ </div>
1166
+ """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1167
 
1168
  with gr.Row():
1169
+ with gr.Column(scale=1):
1170
+ gr.HTML('<div class="info-box">⚙️ <b>설정</b></div>')
1171
+
1172
+ with gr.Accordion("🔑 API 설정", open=True):
1173
+ groq_key = gr.Textbox(label="Groq API Key", type="password", value=GROQ_API_KEY, placeholder="gsk_...")
1174
+ fireworks_key = gr.Textbox(label="Fireworks API Key", type="password", value=FIREWORKS_API_KEY, placeholder="fw_...")
1175
+
1176
+ gr.HTML("""
1177
+ <div class="info-box">
1178
+ 📁 <b>지원 파일 형식</b><br><br>
1179
+ 🖼️ <b>이미지</b>: JPG, PNG, GIF, WebP<br>
1180
+ 📑 <b>문서</b>: PDF, TXT, MD<br>
1181
+ 📄 <b>한글</b>: HWP, HWPX ✨
1182
+ </div>
1183
+ """)
1184
+
1185
+ new_btn = gr.Button("🆕 새 대화 시작", variant="primary")
1186
+
1187
+ with gr.Accordion("📜 대화 기록 (Memory)", open=False):
1188
+ session_list = gr.Dataframe(headers=["ID", "제목", "시간"], interactive=False)
1189
+ refresh_btn = gr.Button("🔄 새로고침", size="sm")
1190
+
1191
+ with gr.Column(scale=3):
1192
+ chatbot = gr.Chatbot(label="💬 AI 대화", height=500)
1193
+
1194
+ with gr.Row():
1195
+ file_upload = gr.File(
1196
+ label="📎 파일 첨부 (HWP/HWPX/PDF/이미지)",
1197
+ file_types=[".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".txt", ".md", ".hwp", ".hwpx"],
1198
+ scale=1,
1199
+ elem_classes=["upload-box"]
1200
+ )
1201
+ msg_input = gr.Textbox(
1202
+ placeholder="💭 메시지를 입력하세요... (파일을 업로드하면 AI가 내용을 읽고 분석합니다)",
1203
+ lines=2,
1204
+ show_label=False,
1205
+ scale=4
1206
+ )
1207
+
1208
+ with gr.Row():
1209
+ submit_btn = gr.Button("🚀 전송", variant="primary", scale=3)
1210
+ clear_btn = gr.Button("🗑️ 지우기", scale=1)
1211
+
1212
+ # Tab 2: HWP 변환기
1213
+ with gr.Tab("📄 HWP 변환기"):
1214
+ gr.HTML("""
1215
+ <div class="feature-box">
1216
+ <div class="feature-title">🔄 HWP/HWPX 파일 변환기</div>
1217
+ <p style="font-family: 'Comic Neue', cursive; font-weight: 700; color: #1F2937;">
1218
+ 한글 문서를 다양한 형식으로 변환합니다. AI가 문서를 읽고 텍스트를 추출합니다.
1219
+ </p>
1220
+ </div>
1221
+ """)
1222
 
1223
  with gr.Row():
1224
+ with gr.Column():
1225
+ gr.HTML('<div class="info-box">📤 <b>파일 업로드</b></div>')
1226
+ hwp_input = gr.File(
1227
+ label="HWP/HWPX 파일 선택",
1228
+ file_types=[".hwp", ".hwpx"],
1229
+ elem_classes=["upload-box"]
1230
+ )
1231
+ format_select = gr.Radio(
1232
+ ["HTML", "ODT (OpenDocument)", "TXT (텍스트)", "Markdown", "XML"],
1233
+ value="TXT (텍스트)",
1234
+ label="📋 변환 형식"
1235
+ )
1236
+ convert_btn = gr.Button("🔄 변환하기", variant="primary", size="lg")
1237
+
1238
+ with gr.Column():
1239
+ gr.HTML('<div class="info-box">📥 <b>변환 결과</b></div>')
1240
+ status_out = gr.Textbox(label="상태", interactive=False)
1241
+ file_out = gr.File(label="다운로드", elem_classes=["download-box"])
1242
 
1243
+ with gr.Accordion("📋 미리보기", open=False):
1244
+ preview_out = gr.Textbox(lines=15, interactive=False)
1245
+
1246
+ gr.HTML("""
1247
+ <div class="info-box">
1248
+ ℹ️ <b>안내</b>: HWPX 파일은 TXT, Markdown, XML 변환만 지원됩니다.
1249
+ </div>
1250
+ """)
1251
+
 
 
 
 
 
 
 
 
1252
  # Footer
1253
  gr.HTML("""
1254
  <div class="footer-comic">
1255
+ <p style="font-family:'Bangers',cursive;font-size:1.8rem;letter-spacing:2px">📄 HWP AI 어시스턴트 🤖</p>
1256
+ <p>AI가 HWP 파일을 읽고, 보고, 말하며, 생각하고 기억합니다!</p>
1257
+ <p>📖 READ👁️ SEE 💬 SPEAK 🧠 THINK • 💾 MEMORY</p>
1258
  <p style="margin-top:10px"><a href="https://www.humangen.ai" target="_blank" style="color:#FACC15;text-decoration:none;font-weight:bold;">🏠 www.humangen.ai</a></p>
1259
  </div>
1260
  """)
1261
+
1262
+ # ============== 이벤트 핸들러 ==============
1263
+ def on_submit(msg, hist, f, sid, gk, fk):
1264
+ if hist is None:
1265
+ hist = []
1266
+ for r in chat_response(msg, hist, f, sid, gk, fk):
1267
+ yield r[0], r[1], "", None
1268
+
1269
+ submit_btn.click(on_submit, [msg_input, chatbot, file_upload, session_state, groq_key, fireworks_key],
1270
+ [chatbot, session_state, msg_input, file_upload])
1271
+ msg_input.submit(on_submit, [msg_input, chatbot, file_upload, session_state, groq_key, fireworks_key],
1272
+ [chatbot, session_state, msg_input, file_upload])
1273
+
1274
+ new_btn.click(lambda: ([], create_session(), None, ""), outputs=[chatbot, session_state, file_upload, msg_input])
1275
+ clear_btn.click(lambda: ([], None, ""), outputs=[chatbot, file_upload, msg_input])
1276
+
1277
+ def refresh():
1278
+ sessions = get_all_sessions()
1279
+ return [[s["session_id"][:8], s["title"] or "제목없음", s["updated_at"][:16] if s["updated_at"] else ""] for s in sessions]
1280
+
1281
+ refresh_btn.click(refresh, outputs=[session_list])
1282
+
1283
+ def select_session(evt: gr.SelectData, data):
1284
+ if evt.index[0] < len(data):
1285
+ for s in get_all_sessions():
1286
+ if s["session_id"].startswith(data[evt.index[0]][0]):
1287
+ return load_session(s["session_id"])
1288
+ return [], ""
1289
+
1290
+ session_list.select(select_session, [session_list], [chatbot, session_state])
1291
+ convert_btn.click(convert_hwp, [hwp_input, format_select], [file_out, status_out, preview_out])
1292
+ demo.load(refresh, outputs=[session_list])
1293
 
1294
+ if __name__ == "__main__":
1295
+ demo.launch(ssr_mode=False)