Spaces:
Sleeping
Sleeping
Upload 20 files
Browse files- %E5%B9%BA%E9%B8%A1%E8%A1%80%E6%88%98_mGDL.txt +120 -0
- %E9%BA%BB%E5%B0%86%E6%B8%B8%E6%88%8FmGDL%E9%80%9A%E7%94%A8%E8%AF%AD%E6%B3%95.txt +160 -0
- UI00001.jpg +0 -0
- UI00002.jpg +0 -0
- UI00003.jpg +0 -0
- UI00004.jpg +0 -0
- ai_service.py +239 -0
- app.py +537 -0
- bot.png +0 -0
- cache_manager.py +190 -0
- config.py +34 -0
- default_content.py +101 -0
- env_example.txt +30 -0
- extract_gdl_optimized.py +199 -0
- file_handler.py +109 -0
- landlord.png +0 -0
- prompt.txt +192 -0
- requirements.txt +2 -0
- security.py +249 -0
- styles.py +232 -0
%E5%B9%BA%E9%B8%A1%E8%A1%80%E6%88%98_mGDL.txt
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(game "YaoJi_XueZhan" (version "1.0.0")
|
| 2 |
+
;; ===== Rule-Core: 玩法本体 =====
|
| 3 |
+
(players 4)
|
| 4 |
+
(tileset
|
| 5 |
+
(suits { "wan" "tong" "tiao" })
|
| 6 |
+
(ranks 1..9)
|
| 7 |
+
(copies 4)
|
| 8 |
+
(honors 0) ; 无字牌
|
| 9 |
+
(hongzhong 0) ; 无红中
|
| 10 |
+
(total 108)
|
| 11 |
+
)
|
| 12 |
+
(seats { "A1" "A2" "A3" "A4" })
|
| 13 |
+
(turn_order cyclic A1 A2 A3 A4)
|
| 14 |
+
(actions
|
| 15 |
+
(allow_chi false)
|
| 16 |
+
(allow_peng true)
|
| 17 |
+
(allow_gang { "concealed" "melded" "added" })
|
| 18 |
+
(one_tile_multi_claim true) ; 文档若未指明“同张可同时被胡/碰杠”,此处保持关闭
|
| 19 |
+
)
|
| 20 |
+
(phases [ "choose_que" "exchange_three" "play" "settle" ])
|
| 21 |
+
|
| 22 |
+
(setup
|
| 23 |
+
(dealer "random")
|
| 24 |
+
(initial_hand 13)
|
| 25 |
+
(choose_que
|
| 26 |
+
(enabled true)
|
| 27 |
+
(must_keep_under_two_suits_to_win true) ; “胡牌时手牌中不能超过2门花色”
|
| 28 |
+
(lock_que true)
|
| 29 |
+
)
|
| 30 |
+
(exchange_three
|
| 31 |
+
(enabled true)
|
| 32 |
+
(mode "random_one_player")
|
| 33 |
+
(require_same_suit true)
|
| 34 |
+
(good_hand_protection
|
| 35 |
+
(enabled true)
|
| 36 |
+
(trigger "other_two_suits_count_each_lt_3")) ; “其余2门花色张数均<3触发好牌不换”
|
| 37 |
+
(exclude_wildcards true) ; “幺鸡不参与换三张”
|
| 38 |
+
)
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
(win_rules
|
| 42 |
+
(allow_discard_win true) ; 点炮胡
|
| 43 |
+
(allow_rob_kong true) ; 抢杠胡
|
| 44 |
+
(allow_multi_win true) ; 一炮多响
|
| 45 |
+
(post_win_continuation
|
| 46 |
+
(mode "xuezhan")
|
| 47 |
+
(end_when_third_player_wins true) ; 有玩家胡后继续,至第3家胡或牌尽结束
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
(scoring
|
| 52 |
+
(base_point 1)
|
| 53 |
+
(self_draw_multiplier 2) ; 自摸×2
|
| 54 |
+
(discard_win_multiplier 1)
|
| 55 |
+
(start_multiplier 8) ; 起胡:8倍
|
| 56 |
+
(cap_multiplier 128) ; 封顶:128倍
|
| 57 |
+
(count_gang_point false) ; 不计杠分
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
;; 倍数番型(“基础番型倍数” + 其余倍数),均为“乘法倍数”,非番制
|
| 61 |
+
(fan_table
|
| 62 |
+
; ---- 基础倍数组 ----
|
| 63 |
+
(yaku pinghu (mult 1))
|
| 64 |
+
(yaku pengpenghu (mult 2))
|
| 65 |
+
(yaku gangshangkaihua (mult 2))
|
| 66 |
+
(yaku gangshangpao (mult 2))
|
| 67 |
+
(yaku qiangganghu (mult 2))
|
| 68 |
+
(yaku haidilao (mult 2))
|
| 69 |
+
(yaku haidipao (mult 2))
|
| 70 |
+
(yaku qidui (mult 4))
|
| 71 |
+
(yaku qingyise (mult 4))
|
| 72 |
+
(yaku jingoudiao (mult 4))
|
| 73 |
+
(yaku longqidui (mult 8))
|
| 74 |
+
(yaku qingpeng (mult 8))
|
| 75 |
+
(yaku shuanglongqidui (mult 16))
|
| 76 |
+
(yaku qingqidui (mult 16))
|
| 77 |
+
(yaku qingjingoudiao (mult 16))
|
| 78 |
+
(yaku sanlongqidui (mult 32))
|
| 79 |
+
(yaku qinglongqidui (mult 32))
|
| 80 |
+
(yaku shibaluohan (mult 64))
|
| 81 |
+
(yaku qingshuanglongqidui (mult 64))
|
| 82 |
+
(yaku qingsanlongqidui (mult 128))
|
| 83 |
+
(yaku qingshibaluohan (mult 256))
|
| 84 |
+
(yaku tianhu (force_cap true)) ; 天胡:封顶
|
| 85 |
+
(yaku dihu (force_cap true)) ; 地胡:封顶
|
| 86 |
+
|
| 87 |
+
; ---- 其他倍数(文档明确列出)----
|
| 88 |
+
(yaku wujichi (mult 2) (desc "无鸡胡"))
|
| 89 |
+
(yaku sanji (mult 4) (desc "三幺鸡"))
|
| 90 |
+
(yaku siji (mult 8) (desc "四幺鸡"))
|
| 91 |
+
(yaku gen (mult 2) (desc "四张相同即可,不一定为杠"))
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
(settlement
|
| 95 |
+
(order [ "flower_pig" "big_call" ]) ; 查花猪 → 查大叫
|
| 96 |
+
(flower_pig (penalty "to_each_non_pig" (amount "cap_multiplier")))
|
| 97 |
+
(big_call (penalty "max_potential_without_selfdraw")) ; “不含自摸倍数”
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
;; ===== Rule-Addons: 玩法插件(不改变合法性的一致性规则细化) =====
|
| 101 |
+
(wildcard
|
| 102 |
+
(type "yaoji") ; 幺鸡为赖子
|
| 103 |
+
(as_any_tile true)
|
| 104 |
+
(allow_meld_with_real_tile_only true) ; “不得空碰/空杠,须与真牌配合”
|
| 105 |
+
(added_kong_replace_wildcard_back true) ; “摸到相同牌可把幺鸡换回手中”
|
| 106 |
+
)
|
| 107 |
+
(kong_rules
|
| 108 |
+
(after_added_kong_if_win_count_as "gangshangkaihua") ; “补杠若可胡计杠上开花”
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
;; ===== Policy Packs: 对局策略(外置,不改变玩法本体) =====
|
| 112 |
+
(policy_ref
|
| 113 |
+
(source "/mnt/data/xuezhan_yj.xlsm")
|
| 114 |
+
(maps
|
| 115 |
+
(deal.opening_patterns "开牌/发牌策略表") ; 起手暖手权重等
|
| 116 |
+
(draw.draw_strategies "摸牌规则/权重曲线") ; 随剩余牌量分段的偏置
|
| 117 |
+
)
|
| 118 |
+
(rng (algo "PCG32") (seed "room|server|fixed"))
|
| 119 |
+
)
|
| 120 |
+
)
|
%E9%BA%BB%E5%B0%86%E6%B8%B8%E6%88%8FmGDL%E9%80%9A%E7%94%A8%E8%AF%AD%E6%B3%95.txt
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(game "<Variant_Name>" (version "1.0.0")
|
| 2 |
+
;; ===== 玩法类型:血战类或血流类 =====
|
| 3 |
+
(game_variant "blood_war" | "blood_flow" | "<custom_variant>") ; 游戏类型:血战、血流或自定义玩法
|
| 4 |
+
|
| 5 |
+
;; ===== Rule-Core: 玩法本体 =====
|
| 6 |
+
(players 4) ; 玩家数量
|
| 7 |
+
(tileset
|
| 8 |
+
(suits { "wan" "tong" "tiao" }) ; 花色:万、条、筒
|
| 9 |
+
(ranks 1..9) ; 牌面 1 至 9
|
| 10 |
+
(copies 4) ; 每张牌有 4 张
|
| 11 |
+
(honors 0) ; 字牌是否启用(可以通过 `special_mechanics` 启用)
|
| 12 |
+
(hongzhong 0) ; 红中是否启用(可以通过 `special_mechanics` 启用)
|
| 13 |
+
(total 108) ; 总共 108 张牌
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
;; ===== Special Mechanics: 特殊机制(幺鸡、红中等) =====
|
| 17 |
+
(special_mechanics
|
| 18 |
+
;; 幺鸡作为赖子牌
|
| 19 |
+
(wildcard
|
| 20 |
+
(name "yaoji") ; 幺鸡为赖子
|
| 21 |
+
(tiles { "Tiao" 1 })
|
| 22 |
+
(substitute_any true) ; 幺鸡可以作为任意牌
|
| 23 |
+
(allow_meld_with_real_tile_only true) ; 不允许空碰/空杠,必须有真实牌
|
| 24 |
+
(added_kong_replace_wildcard_back true) ; 摸到相同牌可以把幺鸡换回手中
|
| 25 |
+
)
|
| 26 |
+
;; 红中作为赖子牌
|
| 27 |
+
(wildcard
|
| 28 |
+
(name "hongzhong") ; 红中为赖子
|
| 29 |
+
(tiles { "Zhong" })
|
| 30 |
+
(substitute_any true)
|
| 31 |
+
(as_special_yaku_on_discard (name "hongzhong_gang") (mult 10) (draw_after true)) ; 红中杠×10
|
| 32 |
+
(allow_single_wait_win true) ; 允许单吊红中胡
|
| 33 |
+
)
|
| 34 |
+
;; 可以继续扩展其他赖子或特殊机制
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
;; ===== Rule-Core: 玩法本体继续 =====
|
| 38 |
+
(seats { "A1" "A2" "A3" "A4" }) ; 玩家座位
|
| 39 |
+
(turn_order cyclic A1 A2 A3 A4) ; 出牌顺序,循环进行
|
| 40 |
+
(actions
|
| 41 |
+
(allow_chi false) ; 吃牌规则
|
| 42 |
+
(allow_peng true) ; 碰牌规则
|
| 43 |
+
(allow_gang { "concealed" "melded" "added" }) ; 杠牌规则:暗杠、明杠、补杠
|
| 44 |
+
(one_tile_multi_claim true) ; 允许同张牌同时被胡/碰/杠
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
(phases [ "choose_que" "exchange_three" "play" "settle" ]) ; 游戏阶段:定缺、换三张、行牌、结算
|
| 48 |
+
|
| 49 |
+
(setup
|
| 50 |
+
(dealer "random") ; 庄家随机选定
|
| 51 |
+
(initial_hand 13) ; 初始牌 13 张
|
| 52 |
+
(choose_que
|
| 53 |
+
(enabled true)
|
| 54 |
+
(must_keep_under_two_suits_to_win true) ; 胡牌时手牌中不能超过2门花色
|
| 55 |
+
(lock_que true) ; 锁定定缺,不能更改
|
| 56 |
+
)
|
| 57 |
+
(exchange_three
|
| 58 |
+
(enabled true)
|
| 59 |
+
(mode "random_one_player") ; 随机换三张
|
| 60 |
+
(require_same_suit true) ; 要求换三张的牌要同花色
|
| 61 |
+
(good_hand_protection
|
| 62 |
+
(enabled true)
|
| 63 |
+
(trigger "other_two_suits_count_each_lt_3")) ; 如果剩余两门花色张数少于3,则保护好牌不换
|
| 64 |
+
(exclude_wildcards true) ; 不允许用红中、幺鸡换牌
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
(win_rules
|
| 69 |
+
(allow_discard_win true) ; 点炮胡
|
| 70 |
+
(allow_rob_kong true) ; 允许抢杠胡
|
| 71 |
+
(allow_multi_win true) ; 一炮多响
|
| 72 |
+
(post_win_continuation
|
| 73 |
+
(mode "xuezhan")
|
| 74 |
+
(end_when_third_player_wins true) ; 继续直到第三位玩家胡牌,或牌尽
|
| 75 |
+
)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
(scoring
|
| 79 |
+
(base_point 1) ; 基础分 1
|
| 80 |
+
(self_draw_multiplier 2) ; 自摸×2
|
| 81 |
+
(discard_win_multiplier 1) ; 点炮×1
|
| 82 |
+
(start_multiplier 8) ; 起胡倍数:8倍
|
| 83 |
+
(cap_multiplier 128) ; 封顶倍数:128倍
|
| 84 |
+
(count_gang_point false) ; 不计杠分
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
;; 动态番型与倍数定义
|
| 88 |
+
(fan_table
|
| 89 |
+
;; ---- 基础番型 ----
|
| 90 |
+
(yaku pinghu (mult 1))
|
| 91 |
+
(yaku pengpenghu (mult 2))
|
| 92 |
+
(yaku gangshangkaihua (mult 2))
|
| 93 |
+
(yaku gangshangpao (mult 2))
|
| 94 |
+
(yaku qiangganghu (mult 2))
|
| 95 |
+
(yaku haidilao (mult 2))
|
| 96 |
+
(yaku haidipao (mult 2))
|
| 97 |
+
(yaku qidui (mult 4))
|
| 98 |
+
(yaku qingyise (mult 4))
|
| 99 |
+
(yaku jingoudiao (mult 4))
|
| 100 |
+
(yaku longqidui (mult 8))
|
| 101 |
+
(yaku qingpeng (mult 8))
|
| 102 |
+
(yaku shuanglongqidui (mult 16))
|
| 103 |
+
(yaku qingqidui (mult 16))
|
| 104 |
+
(yaku qingjingoudiao (mult 16))
|
| 105 |
+
(yaku sanlongqidui (mult 32))
|
| 106 |
+
(yaku qinglongqidui (mult 32))
|
| 107 |
+
(yaku shibaluohan (mult 64))
|
| 108 |
+
(yaku qingshuanglongqidui (mult 64))
|
| 109 |
+
(yaku qingsanlongqidui (mult 128))
|
| 110 |
+
(yaku qingshibaluohan (mult 256))
|
| 111 |
+
(yaku tianhu (force_cap true)) ; 天胡:封顶
|
| 112 |
+
(yaku dihu (force_cap true)) ; 地胡:封顶
|
| 113 |
+
|
| 114 |
+
;; ---- 动态添加的番型(基于特殊机制启用) ----
|
| 115 |
+
;; 幺鸡启用后添加相关番型
|
| 116 |
+
(if (enabled "yaoji")
|
| 117 |
+
(yaku laizihu (mult 5) (desc "赖子胡:含幺鸡的胡牌")) ; 赖子胡番型
|
| 118 |
+
(yaku yaoji_jiafu (mult 2) (desc "幺鸡加番:幺鸡作为加番")) ; 幺鸡加番
|
| 119 |
+
|
| 120 |
+
;; 红中启用后添加相关番型
|
| 121 |
+
(if (enabled "hongzhong")
|
| 122 |
+
(yaku hongzhong_gang (mult 10) (desc "红中杠:红中作为杠牌计分"))
|
| 123 |
+
(yaku hongzhong_hu (mult 3) (desc "红中胡:胡牌时红中作为赖子"))
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
;; ---- 其他倍数 ----
|
| 127 |
+
(yaku wujichi (mult 2) (desc "无鸡胡"))
|
| 128 |
+
(yaku sanji (mult 4) (desc "三幺鸡"))
|
| 129 |
+
(yaku siji (mult 8) (desc "四幺鸡"))
|
| 130 |
+
(yaku gen (mult 2) (desc "四张相同即可,不一定为杠"))
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
(settlement
|
| 134 |
+
(order [ "flower_pig" "big_call" ]) ; 查花猪 → 查大叫
|
| 135 |
+
(flower_pig (penalty "to_each_non_pig" (amount "cap_multiplier")))
|
| 136 |
+
(big_call (penalty "max_potential_without_selfdraw")) ; 不含自摸倍数
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
;; ===== Rule-Addons: 玩法插件(不改变合法性的一致性规则细化) =====
|
| 140 |
+
(wildcard
|
| 141 |
+
(type "none"))
|
| 142 |
+
(kong_rules
|
| 143 |
+
(after_added_kong_if_win_count_as "gangshangkaihua")
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
;; ===== Policy Packs: 对局策略(外置,不改变玩法本体) =====
|
| 147 |
+
(policy_ref
|
| 148 |
+
(source "/mnt/data/xueliu_hz3_v2温暖摸牌.xlsm")
|
| 149 |
+
(maps (deal.opening_warm_pools "温暖摸牌/好牌池配置") (room_binding "房间→暖手策略"))
|
| 150 |
+
(rng (algo "PCG32") (seed "room|server|fixed"))
|
| 151 |
+
)
|
| 152 |
+
(policy_ref
|
| 153 |
+
(source "/mnt/data/疯狂血流摸牌规则.xlsm")
|
| 154 |
+
(maps (draw.draw_strategies "摸牌规则/有无红中分支/余牌分段权重"))
|
| 155 |
+
)
|
| 156 |
+
(policy_ref
|
| 157 |
+
(source "/mnt/data/疯狂血流番型叠加.xlsx")
|
| 158 |
+
(maps (fan_table.stack_matrix "叠加矩阵")) ; 上文已固化为 stack-with;此处保留外部来源追溯
|
| 159 |
+
)
|
| 160 |
+
)
|
UI00001.jpg
ADDED
|
UI00002.jpg
ADDED
|
UI00003.jpg
ADDED
|
UI00004.jpg
ADDED
|
ai_service.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI 服务模块 - 处理与 AI 模型的交互(支持原生流式输出)
|
| 3 |
+
"""
|
| 4 |
+
from openai import OpenAI
|
| 5 |
+
from config import API_KEY, API_BASE_URL, MODEL_NAME, MAX_TOKENS, TEMPERATURE, TOP_P, SYSTEM_PROMPT
|
| 6 |
+
from file_handler import load_gdl_text
|
| 7 |
+
from cache_manager import request_cache
|
| 8 |
+
from security import input_validator
|
| 9 |
+
from default_content import get_default_gdl, get_default_prompt, get_default_example_gdl
|
| 10 |
+
|
| 11 |
+
# 初始化OpenAI客户端(DeepSeek V3兼容)
|
| 12 |
+
client = OpenAI(
|
| 13 |
+
api_key=API_KEY,
|
| 14 |
+
base_url=API_BASE_URL
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ========== 公共小工具 ==========
|
| 19 |
+
def _prepare_messages(message, history, uploaded_files, custom_prompt_text, prompt_mode):
|
| 20 |
+
"""
|
| 21 |
+
组装 messages,保证与非流式/流式两条路径的提示词一致
|
| 22 |
+
"""
|
| 23 |
+
# 1) 选择 System Prompt
|
| 24 |
+
base_sys = (SYSTEM_PROMPT or "").strip()
|
| 25 |
+
user_sys = (custom_prompt_text or "").strip()
|
| 26 |
+
mode = (prompt_mode or "覆盖默认SYSTEM_PROMPT").strip()
|
| 27 |
+
|
| 28 |
+
# 🟢 如果用户没有提供自定义 prompt,则使用默认的 prompt 内容
|
| 29 |
+
if not user_sys:
|
| 30 |
+
default_prompt = get_default_prompt()
|
| 31 |
+
if default_prompt:
|
| 32 |
+
user_sys = default_prompt
|
| 33 |
+
|
| 34 |
+
if user_sys:
|
| 35 |
+
wrapped_user_sys = f"<TEAM_PROMPT>\n{user_sys}\n</TEAM_PROMPT>"
|
| 36 |
+
system_to_use = (base_sys + "\n\n" + wrapped_user_sys) if mode.startswith("合并") else wrapped_user_sys
|
| 37 |
+
else:
|
| 38 |
+
system_to_use = base_sys
|
| 39 |
+
|
| 40 |
+
messages = [{"role": "system", "content": system_to_use}]
|
| 41 |
+
|
| 42 |
+
# 2) 注入上传的 GDL(作为第二条 system)
|
| 43 |
+
# 🟢 如果用户没有上传文件,则使用默认的 GDL 内容
|
| 44 |
+
gdl_spec = load_gdl_text(uploaded_files)
|
| 45 |
+
if not gdl_spec:
|
| 46 |
+
gdl_spec = get_default_gdl()
|
| 47 |
+
|
| 48 |
+
if gdl_spec:
|
| 49 |
+
messages.append({
|
| 50 |
+
"role": "system",
|
| 51 |
+
"content": "以下为用户上传的麻将游戏通用语言(mGDL)规范或示例,请在设计与输出中严格遵循:\n<GDL_SPEC>\n"
|
| 52 |
+
+ gdl_spec + "\n</GDL_SPEC>"
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
# 2.5) 注入示例 GDL 文档(作为参考示例)
|
| 56 |
+
# 🟢 自动加载示例 GDL 文档,供 AI 参考
|
| 57 |
+
example_gdl = get_default_example_gdl()
|
| 58 |
+
if example_gdl:
|
| 59 |
+
messages.append({
|
| 60 |
+
"role": "system",
|
| 61 |
+
"content": "以下为示例 GDL 文档,供您参考设计时使用:\n<EXAMPLE_GDL>\n"
|
| 62 |
+
+ example_gdl + "\n</EXAMPLE_GDL>"
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
# 3) 追加历史对话
|
| 66 |
+
for human, assistant in (history or []):
|
| 67 |
+
if human:
|
| 68 |
+
messages.append({"role": "user", "content": human})
|
| 69 |
+
if assistant:
|
| 70 |
+
messages.append({"role": "assistant", "content": assistant})
|
| 71 |
+
|
| 72 |
+
# 4) 当前输入
|
| 73 |
+
messages.append({"role": "user", "content": message})
|
| 74 |
+
|
| 75 |
+
return messages
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _yield_chunks(text, step=40):
|
| 79 |
+
"""把整段文本切成小块,伪流式输出。"""
|
| 80 |
+
s = str(text or "")
|
| 81 |
+
for i in range(0, len(s), step):
|
| 82 |
+
yield s[i:i + step]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ========== 非流式(保留你原实现,便于兼容) ==========
|
| 89 |
+
def design_mahjong_game(message, history, uploaded_files, custom_prompt_text, prompt_mode):
|
| 90 |
+
"""
|
| 91 |
+
设计麻将玩法的主要函数(非流式)
|
| 92 |
+
"""
|
| 93 |
+
# 输入验证
|
| 94 |
+
is_valid, error_msg = input_validator.validate_message(message)
|
| 95 |
+
if not is_valid:
|
| 96 |
+
return f"❌ 输入验证失败:{error_msg}"
|
| 97 |
+
|
| 98 |
+
is_valid, error_msg = input_validator.validate_custom_prompt(custom_prompt_text)
|
| 99 |
+
if not is_valid:
|
| 100 |
+
return f"❌ 自定义提示词验证失败:{error_msg}"
|
| 101 |
+
|
| 102 |
+
is_valid, error_msg = input_validator.validate_file_list(uploaded_files)
|
| 103 |
+
if not is_valid:
|
| 104 |
+
return f"❌ 文件验证失败:{error_msg}"
|
| 105 |
+
|
| 106 |
+
messages = _prepare_messages(message, history, uploaded_files, custom_prompt_text, prompt_mode)
|
| 107 |
+
|
| 108 |
+
# 仅在“无历史”时启用缓存(沿用你的策略)
|
| 109 |
+
if len(history or []) == 0:
|
| 110 |
+
cached_response = request_cache.get(messages)
|
| 111 |
+
if cached_response:
|
| 112 |
+
return cached_response
|
| 113 |
+
|
| 114 |
+
response = _call_ai_model(messages)
|
| 115 |
+
|
| 116 |
+
if len(history or []) == 0 and response and not response.startswith(("❌", "💥")):
|
| 117 |
+
request_cache.set(messages, response)
|
| 118 |
+
|
| 119 |
+
return response
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _call_ai_model(messages):
|
| 123 |
+
"""
|
| 124 |
+
调用 AI 模型(非流式)
|
| 125 |
+
"""
|
| 126 |
+
try:
|
| 127 |
+
response = client.chat.completions.create(
|
| 128 |
+
model=MODEL_NAME,
|
| 129 |
+
messages=messages,
|
| 130 |
+
temperature=TEMPERATURE,
|
| 131 |
+
top_p=TOP_P,
|
| 132 |
+
max_tokens=MAX_TOKENS,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
content = response.choices[0].message.content
|
| 136 |
+
if not content or content.strip() == "":
|
| 137 |
+
return "🤔 AI 返回了空内容,请尝试重新发送或调整输入。"
|
| 138 |
+
return content
|
| 139 |
+
|
| 140 |
+
except ConnectionError as e:
|
| 141 |
+
return f"🌐 网络连接错误:{str(e)}\n\n请检查网络连接是否正常。"
|
| 142 |
+
except TimeoutError as e:
|
| 143 |
+
return f"⏰ 请求超时:{str(e)}\n\n请稍后重试,��尝试减少输入内容长度。"
|
| 144 |
+
except Exception as e:
|
| 145 |
+
error_type = type(e).__name__
|
| 146 |
+
error_msg = str(e)
|
| 147 |
+
return f"💥 调用失败:{error_type}: {error_msg}\n\n请检查 API Key 是否正确,或网络是否通畅。"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ========== 新增:原生流式 ==========
|
| 151 |
+
def design_mahjong_game_stream(message, history, uploaded_files, custom_prompt_text, prompt_mode):
|
| 152 |
+
"""
|
| 153 |
+
原生流式:逐段 yield 文本片段(字符串)
|
| 154 |
+
"""
|
| 155 |
+
# 1) 输入验证(与非流式一致)
|
| 156 |
+
is_valid, error_msg = input_validator.validate_message(message)
|
| 157 |
+
if not is_valid:
|
| 158 |
+
yield f"❌ 输入验证失败:{error_msg}"
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
is_valid, error_msg = input_validator.validate_custom_prompt(custom_prompt_text)
|
| 162 |
+
if not is_valid:
|
| 163 |
+
yield f"❌ 自定义提示词验证失败:{error_msg}"
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
is_valid, error_msg = input_validator.validate_file_list(uploaded_files)
|
| 167 |
+
if not is_valid:
|
| 168 |
+
yield f"❌ 文件验证失败:{error_msg}"
|
| 169 |
+
return
|
| 170 |
+
|
| 171 |
+
# 2) 组装 messages(与非流式完全一致)
|
| 172 |
+
messages = _prepare_messages(message, history, uploaded_files, custom_prompt_text, prompt_mode)
|
| 173 |
+
|
| 174 |
+
# 3) 无历史时的缓存命中
|
| 175 |
+
no_hist = len(history or []) == 0
|
| 176 |
+
if no_hist:
|
| 177 |
+
cached = request_cache.get(messages)
|
| 178 |
+
if cached:
|
| 179 |
+
for piece in _yield_chunks(cached, step=48):
|
| 180 |
+
yield piece
|
| 181 |
+
return
|
| 182 |
+
|
| 183 |
+
# 4) 原生流式调用(OpenAI兼容API)
|
| 184 |
+
buf = []
|
| 185 |
+
try:
|
| 186 |
+
stream = client.chat.completions.create(
|
| 187 |
+
model=MODEL_NAME,
|
| 188 |
+
messages=messages,
|
| 189 |
+
temperature=TEMPERATURE,
|
| 190 |
+
top_p=TOP_P,
|
| 191 |
+
max_tokens=MAX_TOKENS,
|
| 192 |
+
stream=True,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# 简单的字符级节流(攒到一定长度再刷新,提高前端性能)
|
| 196 |
+
cache_piece = []
|
| 197 |
+
cache_len = 0
|
| 198 |
+
FLUSH_EVERY = 24 # 每凑够 N 字符刷新一次;可按需调整
|
| 199 |
+
|
| 200 |
+
for chunk in stream:
|
| 201 |
+
# 安全地提取增量文本
|
| 202 |
+
delta = None
|
| 203 |
+
if chunk.choices and len(chunk.choices) > 0:
|
| 204 |
+
delta_obj = chunk.choices[0].delta
|
| 205 |
+
if delta_obj and hasattr(delta_obj, 'content'):
|
| 206 |
+
delta = delta_obj.content
|
| 207 |
+
|
| 208 |
+
# 有的帧是控制帧,不含文本
|
| 209 |
+
if not delta:
|
| 210 |
+
continue
|
| 211 |
+
|
| 212 |
+
buf.append(delta)
|
| 213 |
+
cache_piece.append(delta)
|
| 214 |
+
cache_len += len(delta)
|
| 215 |
+
|
| 216 |
+
# 小节流:积累到一定字符再 yield
|
| 217 |
+
if cache_len >= FLUSH_EVERY:
|
| 218 |
+
text_chunk = "".join(cache_piece)
|
| 219 |
+
cache_piece.clear()
|
| 220 |
+
cache_len = 0
|
| 221 |
+
yield text_chunk
|
| 222 |
+
|
| 223 |
+
# 循环结束,把最后没刷出去的片段刷掉
|
| 224 |
+
if cache_piece:
|
| 225 |
+
yield "".join(cache_piece)
|
| 226 |
+
|
| 227 |
+
# 5) 写入缓存(仅无历史 & 正常内容)
|
| 228 |
+
full = "".join(buf).strip()
|
| 229 |
+
if no_hist and full and not full.startswith(("❌", "💥")):
|
| 230 |
+
request_cache.set(messages, full)
|
| 231 |
+
|
| 232 |
+
except ConnectionError as e:
|
| 233 |
+
yield f"\n🌐 网络连接错误:{str(e)}"
|
| 234 |
+
except TimeoutError as e:
|
| 235 |
+
yield f"\n⏰ 请求超时:{str(e)}"
|
| 236 |
+
except Exception as e:
|
| 237 |
+
# 这里不再抛具体 KeyError,而是把异常消息直接展示出来,避免中断生成器
|
| 238 |
+
yield f"\n💥 流式调用失败:{type(e).__name__}: {e}"
|
| 239 |
+
|
app.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
# ===== 你的自定义模块 =====
|
| 5 |
+
from config import APP_TITLE, CHATBOT_HEIGHT, MIN_WIDTH_LEFT, MIN_WIDTH_RIGHT
|
| 6 |
+
from styles import MAHJONG_THEME_CSS
|
| 7 |
+
|
| 8 |
+
# Extra CSS to force dark & counter browser auto-invert
|
| 9 |
+
EXTRA_FIX_CSS = '''/* === Force dark site and neutralize browser-side auto-invert === */
|
| 10 |
+
html, body{ background-color:#0b1220 !important; color-scheme: dark !important; }
|
| 11 |
+
/* When browser/extension applies global invert, add a counter-invert on our app root */
|
| 12 |
+
html.force-dark-fix #app-root{ filter: invert(1) hue-rotate(180deg) !important; }'''
|
| 13 |
+
# Redirect to ?__theme=dark
|
| 14 |
+
FORCE_DARK_REDIRECT = '''<script>
|
| 15 |
+
(function(){
|
| 16 |
+
try{
|
| 17 |
+
const url=new URL(window.location.href);
|
| 18 |
+
if(url.searchParams.get("__theme")!=="dark"){
|
| 19 |
+
url.searchParams.set("__theme","dark");
|
| 20 |
+
window.location.replace(url.toString());
|
| 21 |
+
}
|
| 22 |
+
}catch(e){}
|
| 23 |
+
})();
|
| 24 |
+
</script>'''
|
| 25 |
+
# Detect browser force-dark and set html class
|
| 26 |
+
DETECT_FORCE_DARK = '''<script>
|
| 27 |
+
(function(){
|
| 28 |
+
try{
|
| 29 |
+
const html=document.documentElement;
|
| 30 |
+
const cs=getComputedStyle(html);
|
| 31 |
+
const hasGlobalFilter=(cs.filter && cs.filter!=="none");
|
| 32 |
+
const darkReaderOn=!!document.querySelector('style#dark-reader-style')||!!document.querySelector('meta[name="darkreader"]');
|
| 33 |
+
if(hasGlobalFilter||darkReaderOn){ html.classList.add('force-dark-fix'); }
|
| 34 |
+
}catch(e){}
|
| 35 |
+
})();
|
| 36 |
+
</script>'''
|
| 37 |
+
from ai_service import design_mahjong_game
|
| 38 |
+
# 优先尝试原生流式;没有则自动回退为伪流式
|
| 39 |
+
try:
|
| 40 |
+
from ai_service import design_mahjong_game_stream
|
| 41 |
+
except Exception:
|
| 42 |
+
design_mahjong_game_stream = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ====== 🔧 Hotfix: 兼容 gradio_client 对 additionalProperties(bool) 的解析 ======
|
| 46 |
+
try:
|
| 47 |
+
import gradio_client.utils as _gc_utils
|
| 48 |
+
|
| 49 |
+
_orig_get_type = _gc_utils.get_type
|
| 50 |
+
def _safe_get_type(schema):
|
| 51 |
+
if isinstance(schema, bool):
|
| 52 |
+
return "any" if schema else "never"
|
| 53 |
+
return _orig_get_type(schema)
|
| 54 |
+
_gc_utils.get_type = _safe_get_type
|
| 55 |
+
|
| 56 |
+
_orig_json2py = _gc_utils._json_schema_to_python_type
|
| 57 |
+
def _safe_json2py(schema, defs):
|
| 58 |
+
if isinstance(schema, bool):
|
| 59 |
+
return "any" if schema else "never"
|
| 60 |
+
if isinstance(schema, dict) and "additionalProperties" in schema:
|
| 61 |
+
ap = schema["additionalProperties"]
|
| 62 |
+
if isinstance(ap, bool):
|
| 63 |
+
inner = "any" if ap else "never"
|
| 64 |
+
return f"dict[str, {inner}]"
|
| 65 |
+
return _orig_json2py(schema, defs)
|
| 66 |
+
_gc_utils._json_schema_to_python_type = _safe_json2py
|
| 67 |
+
|
| 68 |
+
_orig_json_schema_to_python_type = _gc_utils.json_schema_to_python_type
|
| 69 |
+
def _safe_json_schema_to_python_type(schema):
|
| 70 |
+
if isinstance(schema, bool):
|
| 71 |
+
return "any" if schema else "never"
|
| 72 |
+
return _orig_json_schema_to_python_type(schema)
|
| 73 |
+
_gc_utils.json_schema_to_python_type = _safe_json_schema_to_python_type
|
| 74 |
+
except Exception:
|
| 75 |
+
pass
|
| 76 |
+
# ====== 🔧 Hotfix 结束 ======
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ==================== 工具函数 ====================
|
| 80 |
+
def _messages_to_tuples(history):
|
| 81 |
+
"""
|
| 82 |
+
将 Chatbot 的 messages 格式([{role, content}, ...])转换为 [(user, bot), ...]。
|
| 83 |
+
若已是 tuples,则原样返回。
|
| 84 |
+
"""
|
| 85 |
+
if not history:
|
| 86 |
+
return []
|
| 87 |
+
|
| 88 |
+
if isinstance(history, list) and history and isinstance(history[0], dict):
|
| 89 |
+
pairs = []
|
| 90 |
+
last_user = None
|
| 91 |
+
for msg in history:
|
| 92 |
+
role = msg.get("role")
|
| 93 |
+
content = msg.get("content", "")
|
| 94 |
+
if role == "user":
|
| 95 |
+
last_user = content
|
| 96 |
+
elif role == "assistant":
|
| 97 |
+
pairs.append((last_user or "", content))
|
| 98 |
+
last_user = None
|
| 99 |
+
return pairs
|
| 100 |
+
|
| 101 |
+
return history
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _chunk_fake_stream(text, step=40):
|
| 105 |
+
"""把整段文本切成小块,伪流式输出。"""
|
| 106 |
+
s = str(text or "")
|
| 107 |
+
for i in range(0, len(s), step):
|
| 108 |
+
yield s[i:i + step]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _load_base64_image(path):
|
| 112 |
+
"""读取本地图片并返回 data URI"""
|
| 113 |
+
import base64
|
| 114 |
+
import pathlib
|
| 115 |
+
|
| 116 |
+
p = pathlib.Path(path)
|
| 117 |
+
if not p.exists():
|
| 118 |
+
return ""
|
| 119 |
+
try:
|
| 120 |
+
data = base64.b64encode(p.read_bytes()).decode("ascii")
|
| 121 |
+
suffix = p.suffix.lower()
|
| 122 |
+
mime = "image/png" if suffix == ".png" else "image/jpeg"
|
| 123 |
+
return f"data:{mime};base64,{data}"
|
| 124 |
+
except Exception:
|
| 125 |
+
return ""
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
HERO_TILE_PATHS = ["UI00001.jpg", "UI00002.jpg", "UI00003.jpg", "UI00004.jpg"]
|
| 129 |
+
HERO_TILE_IMAGES = [_load_base64_image(p) for p in HERO_TILE_PATHS]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _render_hero_tiles():
|
| 133 |
+
tiles = [
|
| 134 |
+
f'<span class="hero-photo" style="background-image:url(\'{src}\');" title="参考图 {idx + 1}"></span>'
|
| 135 |
+
for idx, src in enumerate(HERO_TILE_IMAGES)
|
| 136 |
+
if src
|
| 137 |
+
]
|
| 138 |
+
return "".join(tiles)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def clear_cache():
|
| 142 |
+
"""清空缓存"""
|
| 143 |
+
from cache_manager import file_cache, request_cache
|
| 144 |
+
file_cache.clear()
|
| 145 |
+
request_cache.clear()
|
| 146 |
+
return "✅ 缓存已清空"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def clear_files():
|
| 150 |
+
"""清空文件上传"""
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def update_file_status(files):
|
| 155 |
+
"""更新文件状态显示"""
|
| 156 |
+
if not files:
|
| 157 |
+
return "📁 文件状态:未上传"
|
| 158 |
+
file_list = files if isinstance(files, (list, tuple)) else [files]
|
| 159 |
+
names = []
|
| 160 |
+
for f in file_list:
|
| 161 |
+
if isinstance(f, str):
|
| 162 |
+
names.append(os.path.basename(f))
|
| 163 |
+
else:
|
| 164 |
+
names.append(os.path.basename(getattr(f, "name", str(f))))
|
| 165 |
+
head = "\n".join(f" • {n}" for n in names[:3])
|
| 166 |
+
tail = "\n ..." if len(names) > 3 else ""
|
| 167 |
+
return f"📁 文件状态:已上传 {len(names)} 个文件\n{head}{tail}"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def export_history_to_markdown(history):
|
| 171 |
+
"""将聊天历史导出为 Markdown 文件,并返回文件路径供下载"""
|
| 172 |
+
import time
|
| 173 |
+
import pathlib
|
| 174 |
+
from datetime import datetime
|
| 175 |
+
|
| 176 |
+
pairs = _messages_to_tuples(history)
|
| 177 |
+
|
| 178 |
+
lines = ["# 对话记录", ""]
|
| 179 |
+
lines.append(f"- 导出时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 180 |
+
lines.append("")
|
| 181 |
+
for idx, (user_msg, bot_msg) in enumerate(pairs, start=1):
|
| 182 |
+
lines.append(f"## 轮次 {idx}")
|
| 183 |
+
if user_msg:
|
| 184 |
+
lines.append("**用户**:\n")
|
| 185 |
+
lines.append(user_msg)
|
| 186 |
+
lines.append("")
|
| 187 |
+
if bot_msg:
|
| 188 |
+
lines.append("**助手**:\n")
|
| 189 |
+
lines.append(bot_msg)
|
| 190 |
+
lines.append("")
|
| 191 |
+
|
| 192 |
+
content = "\n".join(lines).strip() + "\n"
|
| 193 |
+
|
| 194 |
+
export_dir = pathlib.Path("exports")
|
| 195 |
+
export_dir.mkdir(parents=True, exist_ok=True)
|
| 196 |
+
filename = export_dir / f"chat_history_{int(time.time())}.md"
|
| 197 |
+
with open(filename, "w", encoding="utf-8") as f:
|
| 198 |
+
f.write(content)
|
| 199 |
+
return str(filename)
|
| 200 |
+
|
| 201 |
+
# ==================== 新增:提取 GDL 和自然语言描述 ====================
|
| 202 |
+
def extract_gdl_and_narrative(content):
|
| 203 |
+
"""提取 GDL 和自然语言部分(支持多种格式变体)"""
|
| 204 |
+
import re
|
| 205 |
+
|
| 206 |
+
# 定义多种可能的标记格式(按优先级排序,支持更多变体)
|
| 207 |
+
gdl_patterns = [
|
| 208 |
+
r"##\s*m?GDL\s*描述", # ## GDL描述 / ## mGDL 描述
|
| 209 |
+
r"m?GDL\s*描述", # GDL描述 / mGDL 描述
|
| 210 |
+
r"##\s*GDL\s*Description", # ## GDL Description(英文)
|
| 211 |
+
r"##\s*m?GDL", # ## GDL / ## mGDL
|
| 212 |
+
r"m?GDL\s*规则代码", # mGDL 规则代码
|
| 213 |
+
r"m?GDL\s*规则", # mGDL 规则
|
| 214 |
+
]
|
| 215 |
+
|
| 216 |
+
narrative_patterns = [
|
| 217 |
+
r"##\s*自然语言规则说明", # ## 自然语言规则说明
|
| 218 |
+
r"##\s*自然语言规则", # ## 自然语言规则
|
| 219 |
+
r"自然语言规则说明", # 自然语言规则说明
|
| 220 |
+
r"自然语言规则", # 自然语言规则
|
| 221 |
+
r"##\s*规则说明", # ## 规则说明
|
| 222 |
+
r"规则说明", # 规则说明
|
| 223 |
+
]
|
| 224 |
+
|
| 225 |
+
# 尝试查找 GDL 部分(支持大小写不敏感)
|
| 226 |
+
gdl_start = -1
|
| 227 |
+
for pattern in gdl_patterns:
|
| 228 |
+
match = re.search(pattern, content, re.IGNORECASE)
|
| 229 |
+
if match:
|
| 230 |
+
gdl_start = match.start()
|
| 231 |
+
break
|
| 232 |
+
|
| 233 |
+
# 尝试查找自然语言部分(支持大小写不敏感)
|
| 234 |
+
narrative_start = -1
|
| 235 |
+
for pattern in narrative_patterns:
|
| 236 |
+
match = re.search(pattern, content, re.IGNORECASE)
|
| 237 |
+
if match:
|
| 238 |
+
narrative_start = match.start()
|
| 239 |
+
break
|
| 240 |
+
|
| 241 |
+
if gdl_start != -1 and narrative_start != -1:
|
| 242 |
+
# 确保顺序正确(GDL应该在自然语言之前)
|
| 243 |
+
if gdl_start >= narrative_start:
|
| 244 |
+
gdl_start, narrative_start = narrative_start, gdl_start
|
| 245 |
+
|
| 246 |
+
# 获取 GDL 部分(从标记开始到自然语言标记之前)
|
| 247 |
+
gdl_content = content[gdl_start:narrative_start].strip()
|
| 248 |
+
|
| 249 |
+
# 获取自然语言部分(从标记开始到结尾)
|
| 250 |
+
narrative_content = content[narrative_start:].strip()
|
| 251 |
+
|
| 252 |
+
return gdl_content, narrative_content
|
| 253 |
+
|
| 254 |
+
elif gdl_start != -1:
|
| 255 |
+
# 只找到GDL,将后面全部作为GDL
|
| 256 |
+
print(f"⚠️ 仅找到GDL标记,将其后内容作为GDL")
|
| 257 |
+
return content[gdl_start:].strip(), ""
|
| 258 |
+
|
| 259 |
+
elif narrative_start != -1:
|
| 260 |
+
# 只找到自然语言,将后面全部作为自然语言
|
| 261 |
+
print(f"⚠️ 仅找到自然语言标记,将其后内容作为自然语言")
|
| 262 |
+
return "", content[narrative_start:].strip()
|
| 263 |
+
|
| 264 |
+
else:
|
| 265 |
+
# 都没找到,返回空
|
| 266 |
+
print(f"⚠️ 提取警告: 未找到GDL和自然语言标记")
|
| 267 |
+
return "", ""
|
| 268 |
+
|
| 269 |
+
def save_gdl_and_narrative(gdl_content, narrative_content):
|
| 270 |
+
"""保存 GDL 和自然语言内容到文件"""
|
| 271 |
+
# 定义文件存储路径
|
| 272 |
+
export_dir = os.path.join("exports")
|
| 273 |
+
os.makedirs(export_dir, exist_ok=True)
|
| 274 |
+
|
| 275 |
+
gdl_file_path = os.path.join(export_dir, "gdl_output.txt")
|
| 276 |
+
narrative_file_path = os.path.join(export_dir, "narrative_output.txt")
|
| 277 |
+
|
| 278 |
+
# 保存 GDL
|
| 279 |
+
with open(gdl_file_path, "w", encoding="utf-8") as f:
|
| 280 |
+
f.write(gdl_content)
|
| 281 |
+
|
| 282 |
+
# 保存自然语言
|
| 283 |
+
with open(narrative_file_path, "w", encoding="utf-8") as f:
|
| 284 |
+
f.write(narrative_content)
|
| 285 |
+
|
| 286 |
+
return gdl_file_path, narrative_file_path
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ==================== Gradio 界面(Mahjong Skin) ====================
|
| 290 |
+
with gr.Blocks(
|
| 291 |
+
theme=gr.themes.Soft(primary_hue='green', neutral_hue='slate'),
|
| 292 |
+
title=APP_TITLE,
|
| 293 |
+
css=MAHJONG_THEME_CSS + EXTRA_FIX_CSS,
|
| 294 |
+
elem_id='app-root'
|
| 295 |
+
) as demo:
|
| 296 |
+
gr.HTML(FORCE_DARK_REDIRECT)
|
| 297 |
+
gr.HTML(DETECT_FORCE_DARK)
|
| 298 |
+
# 顶部 Hero
|
| 299 |
+
hero_tiles_html = _render_hero_tiles()
|
| 300 |
+
gr.HTML(f"""
|
| 301 |
+
<div class="hero">
|
| 302 |
+
<div style="display:flex;align-items:center;gap:12px;">
|
| 303 |
+
<div style="font-size:30px;">🀄️ 麻将玩法灵感工坊 · KOI Lab</div>
|
| 304 |
+
</div>
|
| 305 |
+
<div style="margin-top:6px;opacity:.92;">与 AI 麻将策划智囊对话,快速生成番型体系与玩法流程</div>
|
| 306 |
+
<div class="hero-icons" aria-hidden="true">
|
| 307 |
+
{hero_tiles_html or '<span class="hero-photo"></span>'}
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
""")
|
| 311 |
+
|
| 312 |
+
with gr.Row(equal_height=True):
|
| 313 |
+
# 左侧:设置区
|
| 314 |
+
with gr.Column(scale=1, min_width=MIN_WIDTH_LEFT):
|
| 315 |
+
with gr.Group(elem_classes="side-card"):
|
| 316 |
+
gr.Markdown("### 输入与约束")
|
| 317 |
+
gr.Markdown("✅ **系统已预加载 Mahjong GDL 规范与提示词**,可直接开局。", elem_classes="hint")
|
| 318 |
+
file_uploader = gr.File(
|
| 319 |
+
label="上传自定义 Mahjong GDL/番型示例(.txt,可多选)",
|
| 320 |
+
file_types=[".txt"],
|
| 321 |
+
file_count="multiple",
|
| 322 |
+
type="filepath",
|
| 323 |
+
interactive=True,
|
| 324 |
+
show_label=True,
|
| 325 |
+
container=True,
|
| 326 |
+
scale=1
|
| 327 |
+
)
|
| 328 |
+
custom_prompt_box = gr.Textbox(
|
| 329 |
+
label="自定义 System Prompt(可选)",
|
| 330 |
+
placeholder="系统已经载入麻将玩法专家提示词。如需特殊规则,可在此粘贴。",
|
| 331 |
+
lines=10,
|
| 332 |
+
max_lines=20,
|
| 333 |
+
show_copy_button=True
|
| 334 |
+
)
|
| 335 |
+
prompt_mode = gr.Radio(
|
| 336 |
+
choices=["覆盖默认SYSTEM_PROMPT", "合并(默认在前)"],
|
| 337 |
+
value="覆盖默认SYSTEM_PROMPT",
|
| 338 |
+
label="自定义 Prompt 的使用方式"
|
| 339 |
+
)
|
| 340 |
+
gr.Markdown("💡 提示:系统默认包含麻将通用语法、番型模板与 Prompt;仅当你要替换地方规则时再上传或输入。", elem_classes="hint")
|
| 341 |
+
|
| 342 |
+
with gr.Group(elem_classes="side-card"):
|
| 343 |
+
gr.Markdown("### 使用说明")
|
| 344 |
+
gr.Markdown(
|
| 345 |
+
"- ✅ **快速开始**:直接描述你想要的麻将玩法主题或目标。\n"
|
| 346 |
+
"- 📁 **可选上传**:导入地方番型表或自定义 GDL 模板以便引用。\n"
|
| 347 |
+
"- ✏️ **可选自定义**:针对特殊风格重写 System Prompt。\n"
|
| 348 |
+
"- 🔄 **清空缓存**:切换规则后建议刷新缓存,避免残留设定。"
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
with gr.Group(elem_classes="side-card"):
|
| 352 |
+
gr.Markdown("### 快捷操作")
|
| 353 |
+
with gr.Row():
|
| 354 |
+
clear_cache_btn = gr.Button("清空缓存", variant="secondary")
|
| 355 |
+
clear_files_btn = gr.Button("清空文件", variant="secondary")
|
| 356 |
+
cache_status = gr.Markdown("💾 缓存状态:正常", elem_classes="hint")
|
| 357 |
+
file_status = gr.Markdown("📁 文件状态:使用默认麻将配置(GDL + Prompt 已预加载)", elem_classes="hint")
|
| 358 |
+
|
| 359 |
+
# 右侧:聊天区(手动事件绑定 + 流式输出)
|
| 360 |
+
with gr.Column(scale=2, min_width=MIN_WIDTH_RIGHT):
|
| 361 |
+
with gr.Group(elem_classes="table"):
|
| 362 |
+
chatbot = gr.Chatbot(
|
| 363 |
+
height=CHATBOT_HEIGHT,
|
| 364 |
+
type="messages",
|
| 365 |
+
elem_classes="custom-chatbot",
|
| 366 |
+
avatar_images=("landlord.png", "bot.png"),
|
| 367 |
+
)
|
| 368 |
+
# 用 State 保存 messages 历史
|
| 369 |
+
chat_state = gr.State([]) # list[dict]: [{"role":"user","content":...}, {"role":"assistant","content":...}]
|
| 370 |
+
gdl_file_path = gr.State("")
|
| 371 |
+
narrative_file_path = gr.State("")
|
| 372 |
+
|
| 373 |
+
user_input = gr.Textbox(
|
| 374 |
+
placeholder="例如:为4人竞速推倒胡设计番型与流程;或“做一个双人合作麻将 roguelike” …",
|
| 375 |
+
show_label=False,
|
| 376 |
+
max_lines=5,
|
| 377 |
+
lines=3,
|
| 378 |
+
show_copy_button=True,
|
| 379 |
+
autofocus=True,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
with gr.Row():
|
| 383 |
+
send_btn = gr.Button("发送", variant="primary")
|
| 384 |
+
stop_info = gr.Markdown("", visible=False)
|
| 385 |
+
|
| 386 |
+
# ====== 核心:流式提交回调(生成器) ======
|
| 387 |
+
def on_submit(user_text, history_msgs, files, custom_prompt, mode):
|
| 388 |
+
user_text = (user_text or "").strip()
|
| 389 |
+
if not user_text:
|
| 390 |
+
# 不提交空消息:输出不变
|
| 391 |
+
yield history_msgs, "", history_msgs, "", "" # 🟩【修改】输出数量改为5个
|
| 392 |
+
return
|
| 393 |
+
|
| 394 |
+
# 立即显示“用户消息”
|
| 395 |
+
history = list(history_msgs or [])
|
| 396 |
+
history.append({"role": "user", "content": user_text})
|
| 397 |
+
yield history, "", history, "", ""
|
| 398 |
+
|
| 399 |
+
# 添加空的助手气泡,用于逐步填充
|
| 400 |
+
history.append({"role": "assistant", "content": ""})
|
| 401 |
+
yield history, "", history, "", ""
|
| 402 |
+
|
| 403 |
+
# 流式生成内容
|
| 404 |
+
tuples_hist = _messages_to_tuples(history)
|
| 405 |
+
|
| 406 |
+
if design_mahjong_game_stream is not None:
|
| 407 |
+
try:
|
| 408 |
+
for piece in design_mahjong_game_stream(user_text, tuples_hist, files, custom_prompt, mode):
|
| 409 |
+
if not piece:
|
| 410 |
+
continue
|
| 411 |
+
history[-1]["content"] += str(piece)
|
| 412 |
+
yield history, "", history, "", ""
|
| 413 |
+
except Exception as e:
|
| 414 |
+
history[-1]["content"] += f"\n(流式出错){type(e).__name__}: {e}"
|
| 415 |
+
yield history, "", history, "", ""
|
| 416 |
+
else:
|
| 417 |
+
try:
|
| 418 |
+
full = design_mahjong_game(user_text, tuples_hist, files, custom_prompt, mode)
|
| 419 |
+
except Exception as e:
|
| 420 |
+
full = f"(出错){type(e).__name__}: {e}"
|
| 421 |
+
for piece in _chunk_fake_stream(str(full), step=40):
|
| 422 |
+
history[-1]["content"] += piece
|
| 423 |
+
yield history, "", history, "", ""
|
| 424 |
+
|
| 425 |
+
# 提取 GDL 和自然语言描述并保存
|
| 426 |
+
try:
|
| 427 |
+
gdl_content, narrative_content = extract_gdl_and_narrative(history[-1]["content"])
|
| 428 |
+
|
| 429 |
+
# 保存 GDL 和自然语言文件
|
| 430 |
+
gdl_path, narrative_path = save_gdl_and_narrative(gdl_content, narrative_content)
|
| 431 |
+
|
| 432 |
+
# 返回文件路径,以便下载
|
| 433 |
+
yield history, "", history, gdl_path, narrative_path # 🟩【修改】返回文件路径
|
| 434 |
+
except Exception as e:
|
| 435 |
+
print(f"保存GDL和自然语言文件时出错: {e}")
|
| 436 |
+
yield history, "", history, "", "" # 🟩【修改】返回空路径
|
| 437 |
+
|
| 438 |
+
# 绑定:回车提交(Enter=提交;Shift+Enter=换行由浏览器处理)
|
| 439 |
+
user_input.submit(
|
| 440 |
+
fn=on_submit,
|
| 441 |
+
inputs=[user_input, chat_state, file_uploader, custom_prompt_box, prompt_mode],
|
| 442 |
+
outputs=[chatbot, user_input, chat_state, gdl_file_path, narrative_file_path], # 🟩【修改】输出改为5个
|
| 443 |
+
preprocess=True,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# 绑定:点击"发送"
|
| 447 |
+
send_btn.click(
|
| 448 |
+
fn=on_submit,
|
| 449 |
+
inputs=[user_input, chat_state, file_uploader, custom_prompt_box, prompt_mode],
|
| 450 |
+
outputs=[chatbot, user_input, chat_state, gdl_file_path, narrative_file_path], # 🟩【修改】输出改为5个
|
| 451 |
+
preprocess=True,
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
# 导出对话
|
| 455 |
+
with gr.Row():
|
| 456 |
+
export_btn = gr.Button("导出对话(Markdown)", variant="secondary")
|
| 457 |
+
export_file = gr.File(label="点击下载导出文件", interactive=False)
|
| 458 |
+
|
| 459 |
+
# 清空对话
|
| 460 |
+
with gr.Row():
|
| 461 |
+
clear_dialog_btn = gr.Button("清空对话", variant="secondary")
|
| 462 |
+
|
| 463 |
+
def _clear_chat():
|
| 464 |
+
return [], "", [], "", ""
|
| 465 |
+
|
| 466 |
+
clear_dialog_btn.click(
|
| 467 |
+
fn=_clear_chat,
|
| 468 |
+
inputs=None,
|
| 469 |
+
outputs=[chatbot, user_input, chat_state, gdl_file_path, narrative_file_path], # 🟩【修改】输出改为5个
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
# ==================== 下载按钮部分 ====================
|
| 473 |
+
with gr.Row():
|
| 474 |
+
gr.Markdown("### 下载 GDL 和自然语言描述")
|
| 475 |
+
download_gdl_btn = gr.Button("下载 GDL 文件", variant="secondary") # 下载 GDL 按钮
|
| 476 |
+
download_narrative_btn = gr.Button("下载自然语言文件", variant="secondary") # 下载自然语言按钮
|
| 477 |
+
|
| 478 |
+
download_gdl_file = gr.File(label="GDL 文件", interactive=False) # 文件下载区域
|
| 479 |
+
download_narrative_file = gr.File(label="自然语言文件", interactive=False) # 文件下载区域
|
| 480 |
+
|
| 481 |
+
# 🟩【修改】绑定下载按钮与文件路径 - 修复版本
|
| 482 |
+
def get_gdl_file(gdl_path):
|
| 483 |
+
if gdl_path and os.path.exists(gdl_path):
|
| 484 |
+
return gdl_path
|
| 485 |
+
return None
|
| 486 |
+
|
| 487 |
+
def get_narrative_file(narrative_path):
|
| 488 |
+
if narrative_path and os.path.exists(narrative_path):
|
| 489 |
+
return narrative_path
|
| 490 |
+
return None
|
| 491 |
+
|
| 492 |
+
# 绑定下载按钮与文件路径
|
| 493 |
+
download_gdl_btn.click(
|
| 494 |
+
fn=get_gdl_file,
|
| 495 |
+
inputs=[gdl_file_path], # 🟩【修改】接收State中的路径
|
| 496 |
+
outputs=[download_gdl_file]
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
download_narrative_btn.click(
|
| 500 |
+
fn=get_narrative_file,
|
| 501 |
+
inputs=[narrative_file_path], # 🟩【修改】接收State中的路径
|
| 502 |
+
outputs=[download_narrative_file]
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# ==================== 事件绑定(左侧) ====================
|
| 506 |
+
clear_cache_btn.click(fn=clear_cache, outputs=[cache_status])
|
| 507 |
+
clear_files_btn.click(fn=clear_files, outputs=[file_uploader])
|
| 508 |
+
file_uploader.change(fn=update_file_status, inputs=[file_uploader], outputs=[file_status])
|
| 509 |
+
|
| 510 |
+
# 导出按钮
|
| 511 |
+
def _export_wrapper(chat_history):
|
| 512 |
+
try:
|
| 513 |
+
path = export_history_to_markdown(chat_history)
|
| 514 |
+
return path
|
| 515 |
+
except Exception as e:
|
| 516 |
+
import tempfile
|
| 517 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".md")
|
| 518 |
+
with open(tmp.name, 'w', encoding='utf-8') as f:
|
| 519 |
+
f.write(f"导出失败:{type(e).__name__}: {e}\n")
|
| 520 |
+
return tmp.name
|
| 521 |
+
|
| 522 |
+
export_btn.click(fn=_export_wrapper, inputs=[chatbot], outputs=[export_file])
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
# ==================== 启动应用 ====================
|
| 526 |
+
if __name__ == "__main__":
|
| 527 |
+
# 兼容不同 gradio 版本:
|
| 528 |
+
# - 有的版本 queue() 不接受 concurrency_count/status_update_rate
|
| 529 |
+
# - 有的版本甚至不需要手动 queue()
|
| 530 |
+
app = demo
|
| 531 |
+
try:
|
| 532 |
+
app = demo.queue() # 不带参数,启用队列以支持生成器流式
|
| 533 |
+
except TypeError:
|
| 534 |
+
# 某些版本 queue() 可能参数或签名不同,直接跳过即可
|
| 535 |
+
app = demo
|
| 536 |
+
app.launch(share=True, show_api=False)
|
| 537 |
+
|
bot.png
ADDED
|
cache_manager.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
缓存管理模块 - 提供文件缓存和请求缓存功能
|
| 3 |
+
"""
|
| 4 |
+
import hashlib
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class FileCache:
|
| 11 |
+
"""文件缓存管理器"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, cache_dir: str = ".cache", max_age: int = 3600):
|
| 14 |
+
"""
|
| 15 |
+
初始化文件缓存
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
cache_dir: 缓存目录
|
| 19 |
+
max_age: 缓存最大存活时间(秒)
|
| 20 |
+
"""
|
| 21 |
+
self.cache_dir = cache_dir
|
| 22 |
+
self.max_age = max_age
|
| 23 |
+
self._ensure_cache_dir()
|
| 24 |
+
|
| 25 |
+
def _ensure_cache_dir(self):
|
| 26 |
+
"""确保缓存目录存在"""
|
| 27 |
+
if not os.path.exists(self.cache_dir):
|
| 28 |
+
os.makedirs(self.cache_dir, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
def _get_cache_key(self, file_path: str) -> str:
|
| 31 |
+
"""生成缓存键"""
|
| 32 |
+
# 使用文件路径和修改时间生成唯一键
|
| 33 |
+
stat = os.stat(file_path)
|
| 34 |
+
key_data = f"{file_path}:{stat.st_mtime}:{stat.st_size}"
|
| 35 |
+
return hashlib.md5(key_data.encode()).hexdigest()
|
| 36 |
+
|
| 37 |
+
def _get_cache_path(self, cache_key: str) -> str:
|
| 38 |
+
"""获取缓存文件路径"""
|
| 39 |
+
return os.path.join(self.cache_dir, f"{cache_key}.cache")
|
| 40 |
+
|
| 41 |
+
def get(self, file_path: str) -> Optional[str]:
|
| 42 |
+
"""
|
| 43 |
+
从缓存获取文件内容
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
file_path: 文件路径
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
文件内容或None(如果缓存不存在或过期)
|
| 50 |
+
"""
|
| 51 |
+
if not os.path.exists(file_path):
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
cache_key = self._get_cache_key(file_path)
|
| 55 |
+
cache_path = self._get_cache_path(cache_key)
|
| 56 |
+
|
| 57 |
+
if not os.path.exists(cache_path):
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
# 检查缓存是否过期
|
| 61 |
+
cache_age = time.time() - os.path.getmtime(cache_path)
|
| 62 |
+
if cache_age > self.max_age:
|
| 63 |
+
os.remove(cache_path)
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
with open(cache_path, 'r', encoding='utf-8') as f:
|
| 68 |
+
return f.read()
|
| 69 |
+
except Exception:
|
| 70 |
+
# 缓存文件损坏,删除它
|
| 71 |
+
if os.path.exists(cache_path):
|
| 72 |
+
os.remove(cache_path)
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
def set(self, file_path: str, content: str) -> bool:
|
| 76 |
+
"""
|
| 77 |
+
将文件内容存入缓存
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
file_path: 文件路径
|
| 81 |
+
content: 文件内容
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
是否成功缓存
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
cache_key = self._get_cache_key(file_path)
|
| 88 |
+
cache_path = self._get_cache_path(cache_key)
|
| 89 |
+
|
| 90 |
+
with open(cache_path, 'w', encoding='utf-8') as f:
|
| 91 |
+
f.write(content)
|
| 92 |
+
return True
|
| 93 |
+
except Exception:
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
def clear(self):
|
| 97 |
+
"""清空所有缓存"""
|
| 98 |
+
if os.path.exists(self.cache_dir):
|
| 99 |
+
for file in os.listdir(self.cache_dir):
|
| 100 |
+
if file.endswith('.cache'):
|
| 101 |
+
os.remove(os.path.join(self.cache_dir, file))
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class RequestCache:
|
| 105 |
+
"""请求缓存管理器"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, max_size: int = 100, max_age: int = 1800):
|
| 108 |
+
"""
|
| 109 |
+
初始化请求缓存
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
max_size: 最大缓存条目数
|
| 113 |
+
max_age: 缓存最大存活时间(秒)
|
| 114 |
+
"""
|
| 115 |
+
self.max_size = max_size
|
| 116 |
+
self.max_age = max_age
|
| 117 |
+
self._cache: Dict[str, Dict[str, Any]] = {}
|
| 118 |
+
|
| 119 |
+
def _get_cache_key(self, messages: list) -> str:
|
| 120 |
+
"""生成请求缓存键"""
|
| 121 |
+
# 使用消息内容生成唯一键
|
| 122 |
+
key_data = str(messages)
|
| 123 |
+
return hashlib.md5(key_data.encode()).hexdigest()
|
| 124 |
+
|
| 125 |
+
def _cleanup_expired(self):
|
| 126 |
+
"""清理过期的缓存条目"""
|
| 127 |
+
current_time = time.time()
|
| 128 |
+
expired_keys = []
|
| 129 |
+
|
| 130 |
+
for key, data in self._cache.items():
|
| 131 |
+
if current_time - data['timestamp'] > self.max_age:
|
| 132 |
+
expired_keys.append(key)
|
| 133 |
+
|
| 134 |
+
for key in expired_keys:
|
| 135 |
+
del self._cache[key]
|
| 136 |
+
|
| 137 |
+
def _cleanup_oldest(self):
|
| 138 |
+
"""清理最旧的缓存条目"""
|
| 139 |
+
if len(self._cache) >= self.max_size:
|
| 140 |
+
# 找到最旧的条目
|
| 141 |
+
oldest_key = min(self._cache.keys(),
|
| 142 |
+
key=lambda k: self._cache[k]['timestamp'])
|
| 143 |
+
del self._cache[oldest_key]
|
| 144 |
+
|
| 145 |
+
def get(self, messages: list) -> Optional[str]:
|
| 146 |
+
"""
|
| 147 |
+
从缓存获取响应
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
messages: 消息列表
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
缓存的响应或None
|
| 154 |
+
"""
|
| 155 |
+
self._cleanup_expired()
|
| 156 |
+
|
| 157 |
+
cache_key = self._get_cache_key(messages)
|
| 158 |
+
if cache_key in self._cache:
|
| 159 |
+
return self._cache[cache_key]['response']
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
def set(self, messages: list, response: str) -> bool:
|
| 163 |
+
"""
|
| 164 |
+
将响应存入缓存
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
messages: 消息列表
|
| 168 |
+
response: 响应内容
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
是否成功缓存
|
| 172 |
+
"""
|
| 173 |
+
self._cleanup_expired()
|
| 174 |
+
self._cleanup_oldest()
|
| 175 |
+
|
| 176 |
+
cache_key = self._get_cache_key(messages)
|
| 177 |
+
self._cache[cache_key] = {
|
| 178 |
+
'response': response,
|
| 179 |
+
'timestamp': time.time()
|
| 180 |
+
}
|
| 181 |
+
return True
|
| 182 |
+
|
| 183 |
+
def clear(self):
|
| 184 |
+
"""清空所有缓存"""
|
| 185 |
+
self._cache.clear()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# 全局缓存实例
|
| 189 |
+
file_cache = FileCache()
|
| 190 |
+
request_cache = RequestCache()
|
config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
配置文件 - 集中管理应用配置
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# ==================== API 配置 ====================
|
| 7 |
+
# DeepSeek V3 API 配置
|
| 8 |
+
API_KEY = os.getenv("DEEPSEEK_API_KEY") or "tmBBchNoClGSrUmV"
|
| 9 |
+
API_BASE_URL = os.getenv("DEEPSEEK_BASE_URL") or "http://env-cvcgvp6m1hkmaaarobeg-cn-wulanchabu.alicloudapi.com/v1"
|
| 10 |
+
MODEL_NAME = os.getenv("MODEL_NAME") or "DeepSeek-V3-0324"
|
| 11 |
+
|
| 12 |
+
if not API_KEY:
|
| 13 |
+
print("⚠️ 警告:未找到 DEEPSEEK_API_KEY 环境变量")
|
| 14 |
+
print("请设置环境变量:export DEEPSEEK_API_KEY='your-api-key'")
|
| 15 |
+
|
| 16 |
+
# 模型参数配置
|
| 17 |
+
MAX_TOKENS = 8192
|
| 18 |
+
TEMPERATURE = 0.85
|
| 19 |
+
TOP_P = 0.9
|
| 20 |
+
|
| 21 |
+
# ==================== 文件处理配置 ====================
|
| 22 |
+
# 上传GDL文本的本地合并上限(仅本地拼接、非服务端限制)
|
| 23 |
+
MAX_FILE_CHARS = 200000
|
| 24 |
+
SUPPORTED_FILE_TYPES = [".txt"]
|
| 25 |
+
|
| 26 |
+
# ==================== 默认提示词 ====================
|
| 27 |
+
SYSTEM_PROMPT = """你是一位资深的麻将与东亚棋牌玩法设计专家,熟悉各地番型体系、操作节奏与现代桌游融合设计手法。
|
| 28 |
+
请根据用户需求,输出兼具创新性与可落地性的麻将玩法方案(包含番型结构、组件、流程与体验亮点)。"""
|
| 29 |
+
|
| 30 |
+
# ==================== UI 配置 ====================
|
| 31 |
+
APP_TITLE = "🀄️ 麻将玩法创意工坊"
|
| 32 |
+
CHATBOT_HEIGHT = 640
|
| 33 |
+
MIN_WIDTH_LEFT = 320
|
| 34 |
+
MIN_WIDTH_RIGHT = 640
|
default_content.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
默认内容模块 - 存储预加载的 GDL 规范和 System Prompt
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# 默认 GDL 规范文件路径
|
| 7 |
+
DEFAULT_GDL_FILE = "麻将游戏mGDL通用语法.txt"
|
| 8 |
+
|
| 9 |
+
# 默认 System Prompt 文件路径
|
| 10 |
+
DEFAULT_PROMPT_FILE = "prompt.txt"
|
| 11 |
+
|
| 12 |
+
# 默认示例 GDL 文档路径
|
| 13 |
+
DEFAULT_EXAMPLE_GDL_FILE = "幺鸡血战_mGDL.txt"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def load_default_gdl():
|
| 17 |
+
"""
|
| 18 |
+
加载默认的 GDL 规范内容
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
str: GDL 规范文本,如果文件不存在则返回空字符串
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
gdl_path = os.path.join(os.path.dirname(__file__), DEFAULT_GDL_FILE)
|
| 25 |
+
if os.path.exists(gdl_path):
|
| 26 |
+
with open(gdl_path, "r", encoding="utf-8") as f:
|
| 27 |
+
return f.read()
|
| 28 |
+
except Exception as e:
|
| 29 |
+
print(f"⚠️ 加载默认 GDL 文件失败:{e}")
|
| 30 |
+
return ""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_default_prompt():
|
| 34 |
+
"""
|
| 35 |
+
加载默认的 System Prompt 内容
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
str: System Prompt 文本,如果文件不存在则返回空字符串
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
prompt_path = os.path.join(os.path.dirname(__file__), DEFAULT_PROMPT_FILE)
|
| 42 |
+
if os.path.exists(prompt_path):
|
| 43 |
+
with open(prompt_path, "r", encoding="utf-8") as f:
|
| 44 |
+
return f.read()
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"⚠️ 加载默认 Prompt 文件失败:{e}")
|
| 47 |
+
return ""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def load_default_example_gdl():
|
| 51 |
+
"""
|
| 52 |
+
加载默认的示例 GDL 文档内容
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
str: 示例 GDL 文本,如果文件不存在则返回空字符串
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
example_path = os.path.join(os.path.dirname(__file__), DEFAULT_EXAMPLE_GDL_FILE)
|
| 59 |
+
if os.path.exists(example_path):
|
| 60 |
+
with open(example_path, "r", encoding="utf-8") as f:
|
| 61 |
+
return f.read()
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"⚠️ 加载默认示例 GDL 文件失败:{e}")
|
| 64 |
+
return ""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# 在模块加载时预加载内容(提高性能)
|
| 68 |
+
DEFAULT_GDL_CONTENT = load_default_gdl()
|
| 69 |
+
DEFAULT_PROMPT_CONTENT = load_default_prompt()
|
| 70 |
+
DEFAULT_EXAMPLE_GDL_CONTENT = load_default_example_gdl()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def get_default_gdl():
|
| 74 |
+
"""
|
| 75 |
+
获取默认的 GDL 规范内容(使用预加载的内容)
|
| 76 |
+
|
| 77 |
+
Returns:
|
| 78 |
+
str: GDL 规范文本
|
| 79 |
+
"""
|
| 80 |
+
return DEFAULT_GDL_CONTENT
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def get_default_prompt():
|
| 84 |
+
"""
|
| 85 |
+
获取默认的 System Prompt 内容(使用预加载的内容)
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
str: System Prompt 文本
|
| 89 |
+
"""
|
| 90 |
+
return DEFAULT_PROMPT_CONTENT
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def get_default_example_gdl():
|
| 94 |
+
"""
|
| 95 |
+
获取默认的示例 GDL 文档内容(使用预加载的内容)
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
str: 示例 GDL 文本
|
| 99 |
+
"""
|
| 100 |
+
return DEFAULT_EXAMPLE_GDL_CONTENT
|
| 101 |
+
|
env_example.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DeepSeek V3 API 环境变量配置示例
|
| 2 |
+
# 如果不设置这些环境变量,程序会使用 config.py 中的默认值
|
| 3 |
+
|
| 4 |
+
# DeepSeek V3 API Key
|
| 5 |
+
DEEPSEEK_API_KEY=tmBBchNoClGSrUmV
|
| 6 |
+
|
| 7 |
+
# DeepSeek V3 API Base URL
|
| 8 |
+
DEEPSEEK_BASE_URL=http://env-cvcgvp6m1hkmaaarobeg-cn-wulanchabu.alicloudapi.com/v1
|
| 9 |
+
|
| 10 |
+
# 模型名称
|
| 11 |
+
MODEL_NAME=DeepSeek-V3-0324
|
| 12 |
+
|
| 13 |
+
# ===============================
|
| 14 |
+
# 使用方法:
|
| 15 |
+
# ===============================
|
| 16 |
+
#
|
| 17 |
+
# 在 Linux/Mac 上:
|
| 18 |
+
# export DEEPSEEK_API_KEY="your-api-key"
|
| 19 |
+
# export DEEPSEEK_BASE_URL="your-base-url"
|
| 20 |
+
# export MODEL_NAME="DeepSeek-V3-0324"
|
| 21 |
+
#
|
| 22 |
+
# 在 Windows 上:
|
| 23 |
+
# set DEEPSEEK_API_KEY=your-api-key
|
| 24 |
+
# set DEEPSEEK_BASE_URL=your-base-url
|
| 25 |
+
# set MODEL_NAME=DeepSeek-V3-0324
|
| 26 |
+
#
|
| 27 |
+
# 或使用 .env 文件(需要安装 python-dotenv):
|
| 28 |
+
# 1. 将本文件重命名为 .env
|
| 29 |
+
# 2. pip install python-dotenv
|
| 30 |
+
# 3. 在代码中添加:from dotenv import load_dotenv; load_dotenv()
|
extract_gdl_optimized.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
优化的 GDL 和自然语言提取函数
|
| 3 |
+
可以直接复制到 app.py 中使用
|
| 4 |
+
"""
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def extract_gdl_and_narrative(content):
|
| 9 |
+
"""
|
| 10 |
+
提取 GDL 和自然语言部分(优化版 - 平衡功能与复杂度)
|
| 11 |
+
|
| 12 |
+
支持的格式:
|
| 13 |
+
- ## GDL描述 / ## GDL 描述
|
| 14 |
+
- ##GDL描述 / GDL描述
|
| 15 |
+
- ## 自然语言规则说明 / ##自然语言规则说明
|
| 16 |
+
- 以及其他常见变体
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
content: AI 生成的完整文本
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
tuple: (gdl_content, narrative_content)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
# ========== 定义标记模式(按优先级排序) ==========
|
| 26 |
+
gdl_patterns = [
|
| 27 |
+
r"##\s*GDL\s*描述", # ## GDL描述 或 ## GDL 描述(推荐格式)
|
| 28 |
+
r"##\s*GDL描述", # ##GDL描述
|
| 29 |
+
r"GDL\s*描述", # GDL描述 或 GDL 描述
|
| 30 |
+
r"##\s*GDL\s*Description", # ## GDL Description(英文)
|
| 31 |
+
r"##\s*GDL", # ## GDL(简化版)
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
narrative_patterns = [
|
| 35 |
+
r"##\s*自然语言规则说明", # ## 自然语言规则说明(推荐格式)
|
| 36 |
+
r"##\s*自然语言规则", # ## 自然语言规则
|
| 37 |
+
r"自然语言规则说明", # 自然语言规则说明
|
| 38 |
+
r"自然语言规则", # 自然语言规则
|
| 39 |
+
r"##\s*Natural\s*Language", # ## Natural Language(英文)
|
| 40 |
+
r"规则说明", # 规则说明(简化版)
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
# ========== 查找标记位置 ==========
|
| 44 |
+
gdl_start = -1
|
| 45 |
+
gdl_marker = None
|
| 46 |
+
for pattern in gdl_patterns:
|
| 47 |
+
match = re.search(pattern, content, re.IGNORECASE)
|
| 48 |
+
if match:
|
| 49 |
+
gdl_start = match.start()
|
| 50 |
+
gdl_marker = match.group()
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
narrative_start = -1
|
| 54 |
+
narrative_marker = None
|
| 55 |
+
for pattern in narrative_patterns:
|
| 56 |
+
match = re.search(pattern, content, re.IGNORECASE)
|
| 57 |
+
if match:
|
| 58 |
+
narrative_start = match.start()
|
| 59 |
+
narrative_marker = match.group()
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
# ========== 处理提取结果 ==========
|
| 63 |
+
if gdl_start != -1 and narrative_start != -1:
|
| 64 |
+
# 正常情况:两个标记都找到
|
| 65 |
+
|
| 66 |
+
# 检查顺序(GDL 应该在自然语言之前)
|
| 67 |
+
if gdl_start >= narrative_start:
|
| 68 |
+
print(f"⚠️ 警告: 标记顺序异常,已自动纠正")
|
| 69 |
+
gdl_start, narrative_start = narrative_start, gdl_start
|
| 70 |
+
|
| 71 |
+
# 提取内容
|
| 72 |
+
gdl_content = content[gdl_start:narrative_start].strip()
|
| 73 |
+
narrative_content = content[narrative_start:].strip()
|
| 74 |
+
|
| 75 |
+
# 验证长度
|
| 76 |
+
if len(gdl_content) < 20 or len(narrative_content) < 20:
|
| 77 |
+
print(f"⚠️ 警告: 提取内容过短 (GDL:{len(gdl_content)}, 自然语言:{len(narrative_content)})")
|
| 78 |
+
|
| 79 |
+
print(f"✅ 提取成功: GDL({len(gdl_content)}字符), 自然语言({len(narrative_content)}字符)")
|
| 80 |
+
return gdl_content, narrative_content
|
| 81 |
+
|
| 82 |
+
elif gdl_start != -1:
|
| 83 |
+
# 只找到 GDL 标记
|
| 84 |
+
print(f"⚠️ 仅找到GDL标记 '{gdl_marker}',将其后全部内容作为GDL")
|
| 85 |
+
gdl_content = content[gdl_start:].strip()
|
| 86 |
+
return gdl_content, ""
|
| 87 |
+
|
| 88 |
+
elif narrative_start != -1:
|
| 89 |
+
# 只找到自然语言标记
|
| 90 |
+
print(f"⚠️ 仅找到自然语言标记 '{narrative_marker}',将其后全部内容作为自然语言")
|
| 91 |
+
narrative_content = content[narrative_start:].strip()
|
| 92 |
+
return "", narrative_content
|
| 93 |
+
|
| 94 |
+
else:
|
| 95 |
+
# 都没找到:尝试智能分割
|
| 96 |
+
print(f"❌ 未找到任何标记,尝试智能分割...")
|
| 97 |
+
return smart_split_content(content)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def smart_split_content(content):
|
| 101 |
+
"""
|
| 102 |
+
智能分割内容(后备方案)
|
| 103 |
+
|
| 104 |
+
策略:通过识别 GDL 代码特征(如 (game "..." )来分割
|
| 105 |
+
"""
|
| 106 |
+
# 查找 GDL 代码块的起始标志
|
| 107 |
+
gdl_code_pattern = r'\(game\s+"[^"]+?"'
|
| 108 |
+
match = re.search(gdl_code_pattern, content)
|
| 109 |
+
|
| 110 |
+
if match:
|
| 111 |
+
gdl_start = match.start()
|
| 112 |
+
|
| 113 |
+
# 简单的括号匹配查找结束位置
|
| 114 |
+
count = 0
|
| 115 |
+
gdl_end = -1
|
| 116 |
+
for i in range(gdl_start, len(content)):
|
| 117 |
+
if content[i] == '(':
|
| 118 |
+
count += 1
|
| 119 |
+
elif content[i] == ')':
|
| 120 |
+
count -= 1
|
| 121 |
+
if count == 0:
|
| 122 |
+
gdl_end = i
|
| 123 |
+
break
|
| 124 |
+
|
| 125 |
+
if gdl_end != -1:
|
| 126 |
+
gdl_content = content[gdl_start:gdl_end + 1].strip()
|
| 127 |
+
narrative_content = content[gdl_end + 1:].strip()
|
| 128 |
+
print(f"🔍 智能分割成功: 识别到GDL代码块")
|
| 129 |
+
return gdl_content, narrative_content
|
| 130 |
+
|
| 131 |
+
print(f"❌ 智能分割失败")
|
| 132 |
+
return "", ""
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ========== 测试代码 ==========
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
# 测试用例
|
| 138 |
+
test_cases = [
|
| 139 |
+
# 测试 1: 标准格式
|
| 140 |
+
"""
|
| 141 |
+
## 游戏名称
|
| 142 |
+
测试游戏
|
| 143 |
+
|
| 144 |
+
## GDL描述
|
| 145 |
+
(game "TestGame"
|
| 146 |
+
(players 3)
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
## 自然语言规则说明
|
| 150 |
+
1. 这是规则
|
| 151 |
+
""",
|
| 152 |
+
# 测试 2: 无空格格式
|
| 153 |
+
"""
|
| 154 |
+
##GDL描述
|
| 155 |
+
(game "TestGame"
|
| 156 |
+
(players 3)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
##自然语言规则说明
|
| 160 |
+
1. 这是规则
|
| 161 |
+
""",
|
| 162 |
+
# 测试 3: 带空格格式
|
| 163 |
+
"""
|
| 164 |
+
## GDL 描述
|
| 165 |
+
(game "TestGame"
|
| 166 |
+
(players 3)
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
## 自然语言规则说明
|
| 170 |
+
1. 这是规则
|
| 171 |
+
""",
|
| 172 |
+
# 测试 4: 无标记(智能分割)
|
| 173 |
+
"""
|
| 174 |
+
(game "TestGame"
|
| 175 |
+
(players 3)
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
这是自然语言规则说明
|
| 179 |
+
""",
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
print("=" * 60)
|
| 183 |
+
print("测试 GDL 提取功能")
|
| 184 |
+
print("=" * 60)
|
| 185 |
+
|
| 186 |
+
for i, test_content in enumerate(test_cases, 1):
|
| 187 |
+
print(f"\n测试用例 {i}:")
|
| 188 |
+
print("-" * 60)
|
| 189 |
+
gdl, narrative = extract_gdl_and_narrative(test_content)
|
| 190 |
+
|
| 191 |
+
if gdl or narrative:
|
| 192 |
+
print(f"GDL 长度: {len(gdl)}")
|
| 193 |
+
print(f"自然语言长度: {len(narrative)}")
|
| 194 |
+
else:
|
| 195 |
+
print("提取失败")
|
| 196 |
+
|
| 197 |
+
print("\n" + "=" * 60)
|
| 198 |
+
|
| 199 |
+
|
file_handler.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
文件处理模块 - 处理文件上传和读取
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
from config import MAX_FILE_CHARS, SUPPORTED_FILE_TYPES
|
| 6 |
+
from cache_manager import file_cache
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def load_gdl_text(uploaded_files, max_chars=MAX_FILE_CHARS):
|
| 10 |
+
"""
|
| 11 |
+
读取上传的 GDL 文本文件
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
uploaded_files: 上传的文件列表
|
| 15 |
+
max_chars: 最大字符数限制
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
str: 合并后的文本内容
|
| 19 |
+
"""
|
| 20 |
+
if not uploaded_files:
|
| 21 |
+
return ""
|
| 22 |
+
|
| 23 |
+
files = uploaded_files if isinstance(uploaded_files, (list, tuple)) else [uploaded_files]
|
| 24 |
+
parts = []
|
| 25 |
+
total_size = 0
|
| 26 |
+
|
| 27 |
+
for f in files:
|
| 28 |
+
try:
|
| 29 |
+
path = getattr(f, "name", None) or str(f)
|
| 30 |
+
|
| 31 |
+
# 检查文件是否存在
|
| 32 |
+
if not os.path.exists(path):
|
| 33 |
+
parts.append(f"\n# FILE_NOT_FOUND: {os.path.basename(path)}\n")
|
| 34 |
+
continue
|
| 35 |
+
|
| 36 |
+
# 检查文件扩展名
|
| 37 |
+
file_ext = os.path.splitext(path)[1].lower()
|
| 38 |
+
if file_ext not in SUPPORTED_FILE_TYPES:
|
| 39 |
+
parts.append(f"\n# UNSUPPORTED_FILE_TYPE: {os.path.basename(path)} (expected: {', '.join(SUPPORTED_FILE_TYPES)})\n")
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
# 检查文件大小
|
| 43 |
+
file_size = os.path.getsize(path)
|
| 44 |
+
if file_size > max_chars:
|
| 45 |
+
parts.append(f"\n# FILE_TOO_LARGE: {os.path.basename(path)} ({file_size} bytes, skipped)\n")
|
| 46 |
+
continue
|
| 47 |
+
|
| 48 |
+
# 尝试从缓存获取
|
| 49 |
+
txt = file_cache.get(path)
|
| 50 |
+
if txt is None:
|
| 51 |
+
# 缓存未命中,读取文件
|
| 52 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
| 53 |
+
txt = fh.read()
|
| 54 |
+
# 存入缓存
|
| 55 |
+
file_cache.set(path, txt)
|
| 56 |
+
|
| 57 |
+
# 检查内容长度
|
| 58 |
+
if len(txt) + total_size > max_chars:
|
| 59 |
+
remaining = max_chars - total_size
|
| 60 |
+
if remaining > 100: # 至少保留100字符
|
| 61 |
+
txt = txt[:remaining] + "\n[...TRUNCATED...]"
|
| 62 |
+
else:
|
| 63 |
+
parts.append(f"\n# FILE_SKIPPED: {os.path.basename(path)} (would exceed limit)\n")
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
parts.append(f"\n# FILE: {os.path.basename(path)}\n{txt}\n")
|
| 67 |
+
total_size += len(txt)
|
| 68 |
+
|
| 69 |
+
except PermissionError:
|
| 70 |
+
parts.append(f"\n# FILE_PERMISSION_ERROR: {os.path.basename(path)} (access denied)\n")
|
| 71 |
+
except UnicodeDecodeError as e:
|
| 72 |
+
parts.append(f"\n# FILE_ENCODING_ERROR: {os.path.basename(path)} ({str(e)})\n")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
parts.append(f"\n# FILE_READ_ERROR: {os.path.basename(path)} ({type(e).__name__}: {str(e)})\n")
|
| 75 |
+
|
| 76 |
+
text = "\n".join(parts).strip()
|
| 77 |
+
return text
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def validate_file_upload(files):
|
| 81 |
+
"""
|
| 82 |
+
验证文件上传的有效性
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
files: 上传的文件列表
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
tuple: (is_valid, error_message)
|
| 89 |
+
"""
|
| 90 |
+
if not files:
|
| 91 |
+
return True, ""
|
| 92 |
+
|
| 93 |
+
file_list = files if isinstance(files, (list, tuple)) else [files]
|
| 94 |
+
|
| 95 |
+
for f in file_list:
|
| 96 |
+
path = getattr(f, "name", None) or str(f)
|
| 97 |
+
|
| 98 |
+
if not os.path.exists(path):
|
| 99 |
+
return False, f"文件不存在: {os.path.basename(path)}"
|
| 100 |
+
|
| 101 |
+
file_ext = os.path.splitext(path)[1].lower()
|
| 102 |
+
if file_ext not in SUPPORTED_FILE_TYPES:
|
| 103 |
+
return False, f"不支持的文件类型: {os.path.basename(path)} (支持: {', '.join(SUPPORTED_FILE_TYPES)})"
|
| 104 |
+
|
| 105 |
+
file_size = os.path.getsize(path)
|
| 106 |
+
if file_size > MAX_FILE_CHARS:
|
| 107 |
+
return False, f"文件过大: {os.path.basename(path)} ({file_size} bytes, 最大: {MAX_FILE_CHARS})"
|
| 108 |
+
|
| 109 |
+
return True, ""
|
landlord.png
ADDED
|
prompt.txt
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## System Prompt
|
| 2 |
+
你是一位麻将游戏设计专家,精通传统麻将、血战麻将、血流麻将等玩法的规则设计。
|
| 3 |
+
你现在的任务是:**在给定 mGDL 通用语法约束下,生成一个新的麻将玩法,并确保所有规则清晰、可编程、无歧义。**
|
| 4 |
+
|
| 5 |
+
你的输出必须包含:
|
| 6 |
+
|
| 7 |
+
1. **游戏名称与理念**
|
| 8 |
+
2. **符合 mGDL 语法的完整 GDL 描述(含中文注释)**
|
| 9 |
+
3. **番型与倍数规则的说明(自然语言)**
|
| 10 |
+
4. **特殊机制规则(如幺鸡、红中等)的说明**
|
| 11 |
+
5. **游戏阶段与玩家操作规则的说明**
|
| 12 |
+
6. **平衡性分析**
|
| 13 |
+
7. **规则自检清单(你自己对上面 1–6 的检查)**
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
### 上下文说明(已给定的参考文件)
|
| 18 |
+
|
| 19 |
+
1. **现有的三种麻将玩法 mGDL 文档**:
|
| 20 |
+
- 幺鸡血战_mGDL.txt
|
| 21 |
+
- 疯狂血战_mGDL.txt
|
| 22 |
+
- 疯狂血流_mGDL.txt
|
| 23 |
+
这些文档已经定义了三种玩法的核心规则、番型、倍数以及特殊机制(如幺鸡赖子、红中赖子)。
|
| 24 |
+
|
| 25 |
+
2. **mGDL 通用语法**:
|
| 26 |
+
- 《麻将游戏mGDL通用语法.txt》给出了统一的骨架:
|
| 27 |
+
- 顶层结构:`(game ...)`
|
| 28 |
+
- Rule-Core:players / tileset / seats / turn_order / actions / phases / setup / win_rules / scoring / fan_table / settlement
|
| 29 |
+
- Rule-Addons:wildcard / kong_rules
|
| 30 |
+
- Policy Packs:policy_ref
|
| 31 |
+
- 你必须 **遵循这套语法** 来生成规则。
|
| 32 |
+
- 你只能使用通用语法和三种示例中已经出现过的字段与结构,不得虚构新的结构(例如:**禁止使用 `zone`、`path`、`board` 等本项目没有定义的字段**)。
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
### 规则严谨性与可编程性要求(非常重要)
|
| 37 |
+
|
| 38 |
+
在设计新玩法和编写 mGDL 时,必须遵守以下原则:
|
| 39 |
+
|
| 40 |
+
1. **所有关键规则必须在 mGDL 中显式写出**
|
| 41 |
+
- 包括但不限于:
|
| 42 |
+
- 是否允许:吃(allow_chi)、碰(allow_peng)、各种杠(allow_gang)、点炮胡(allow_discard_win)、抢杠胡(allow_rob_kong)、一炮多响(allow_multi_win)。
|
| 43 |
+
- 胡后是否继续、已胡玩家是否退出、何时结束牌局:通过 `post_win_continuation` 显式描述,例如:
|
| 44 |
+
- 血战类:`(mode "xuezhan") (winner_exit true) (end_when_third_player_wins true)`
|
| 45 |
+
- 血流类:`(mode "xueliu") (winner_exit false) (keep_turn_order true) (after_gun_next_draw "shooter_next")`
|
| 46 |
+
- 不允许只在自然语言里说“类似疯狂血战/血流”,必须在 mGDL 里完整展开。
|
| 47 |
+
|
| 48 |
+
2. **计分体系必须一致、选一并说清楚**
|
| 49 |
+
你可以从以下三种计分风格中**任选一种**,也可以在其基础上小幅修改,但必须在 mGDL 中清晰表达,并在自然语言说明中写明“本玩法采用 X 风格”:
|
| 50 |
+
- 倍数制:
|
| 51 |
+
- 典型结构:`(base_point 1) (self_draw_multiplier 2) (discard_win_multiplier 1) (start_multiplier 8) (cap_multiplier 128) (fan_table (yaku ... (mult ...)))`
|
| 52 |
+
- 番制 + 番映射倍数(类似疯狂血战):
|
| 53 |
+
- 典型结构:`(scoring (fan_system (base 10)) (fan_cap_by_room {...}) ...) (fan_table (yaku ... (fan n multiplier)))`
|
| 54 |
+
- 血流大倍数制(类似疯狂血流):
|
| 55 |
+
- 典型结构:`(scoring (base_point 1) (self_draw_multiplier 10) (discard_win_multiplier 1) ...) (fan_table (yaku ... (mult ...)))`
|
| 56 |
+
|
| 57 |
+
不允许出现含糊描述,例如“起胡倍数稍高”“封顶较高一些”“倍数可由运营调整”,必须写出具体数字。
|
| 58 |
+
|
| 59 |
+
3. **禁止模糊或不可编程的表达**
|
| 60 |
+
在 mGDL 和自然语言规则说明中都禁止使用以下模糊表达:
|
| 61 |
+
- “一般情况下…”
|
| 62 |
+
- “通常…”
|
| 63 |
+
- “视情况而定…”
|
| 64 |
+
- “可以考虑…”
|
| 65 |
+
- “可由运营调整…”
|
| 66 |
+
- “略有调整即可…”
|
| 67 |
+
- “与 XX 类似,未说明处同 XX 玩法”
|
| 68 |
+
你必须把逻辑展开成 **明确条件和结果**,例如:
|
| 69 |
+
- 错误:`起胡倍数略高于幺鸡血战`
|
| 70 |
+
- 正确:`起胡倍数=16,封顶倍数=256`
|
| 71 |
+
|
| 72 |
+
4. **自然语言规则必须与 mGDL 严格对齐**
|
| 73 |
+
- 不允许文案和代码出现冲突,比如文案里说“不允许抢杠胡”,mGDL 写 `(allow_rob_kong true)`。
|
| 74 |
+
- 你在写完 mGDL 后,生成自然语言说明时要显式对照 mGDL 中的字段来逐项解释。
|
| 75 |
+
|
| 76 |
+
5. **不要留下空洞或未定义的情况**
|
| 77 |
+
- 每一个阶段(定缺、换三张、行牌、结算),都要说明:
|
| 78 |
+
- 触发条件;
|
| 79 |
+
- 玩家可选动作;
|
| 80 |
+
- 阶段结束条件。
|
| 81 |
+
- 若不启用某个阶段(例如不换三张),要显式写:
|
| 82 |
+
- mGDL:`(exchange_three (enabled false))`
|
| 83 |
+
- 文案:说明“本玩法不启用换三张”。
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
### 核心设计要求(你需要在此基础上做出新玩法)
|
| 88 |
+
|
| 89 |
+
根据以下信息,生成一个新的麻将玩法。
|
| 90 |
+
**每一个设计点都必须在 mGDL 中有对应字段,在自然语言说明中有清晰解释。**
|
| 91 |
+
|
| 92 |
+
1. **玩法名称与理念**
|
| 93 |
+
- 给出一个新玩法的名称。
|
| 94 |
+
- 简要描述新玩法的核心理念和创新点(例如:更快节���、更高风险、更强调赖子等)。
|
| 95 |
+
|
| 96 |
+
2. **玩法类型**
|
| 97 |
+
- 在以下三种中选择其一,并在 mGDL 中通过 `(game_variant ...)` 明确声明:
|
| 98 |
+
- `"blood_war"`:血战类,推荐使用类似幺鸡血战 / 疯狂血战的 `post_win_continuation` 模板:
|
| 99 |
+
- 已胡玩家退出本局(winner_exit true)
|
| 100 |
+
- 至第 3 家胡牌或牌摸完结束(end_when_third_player_wins true)
|
| 101 |
+
- `"blood_flow"`:血流类,推荐使用类似疯狂血流的模板:
|
| 102 |
+
- winner_exit false
|
| 103 |
+
- 胡不改顺 / 点炮者下家摸牌(keep_turn_order true, after_gun_next_draw "shooter_next")
|
| 104 |
+
- `"<custom_variant>"`:自定义类型,但仍需在 `win_rules` 里完整定义胡后继续逻辑和结束条件。
|
| 105 |
+
- 自然语言中要解释清楚:“本玩法为 XX 类,胡牌后是否退出/是否可多次胡/何时结束”。
|
| 106 |
+
|
| 107 |
+
3. **特殊机制**
|
| 108 |
+
针对幺鸡/红中等,必须在以下两层都写清楚:
|
| 109 |
+
- `special_mechanics` 或 `wildcard` 段落内的 mGDL 配置;
|
| 110 |
+
- 文案中解释:
|
| 111 |
+
- 是否启用幺鸡赖子;
|
| 112 |
+
- 是否启用红中赖子;
|
| 113 |
+
- 赖子是否可打出、是否算杠、是否可以单吊胡;
|
| 114 |
+
- 补杠时如何处理赖子(例如“摸到相同牌可将赖子换回手中”)。
|
| 115 |
+
|
| 116 |
+
4. **番型与倍数规则**
|
| 117 |
+
- 设计番型时,建议在以下几类中选择组合:
|
| 118 |
+
- 基础番型:平胡、碰碰胡、七对、清一色、金钩钓、龙七对、清碰等;
|
| 119 |
+
- 血流大牌型:大威天龙、万中无双、十八罗汉、绿一色、大车轮、十二金钗等;
|
| 120 |
+
- 与赖子相关的特殊番型:赖子胡、红中杠、无鸡胡、三鸡/四鸡等。
|
| 121 |
+
- 对每个番型,你必须在 `fan_table` 中给出:
|
| 122 |
+
- 唯一名称(yaku name);
|
| 123 |
+
- 番数或倍数(fan / mult);
|
| 124 |
+
- 简短 desc(中文描述)。
|
| 125 |
+
- 必须指出番型是否叠加,以及封顶规则:
|
| 126 |
+
- 番制:设置 `fan_cap_by_room`;
|
| 127 |
+
- 倍数制:设置 `cap_multiplier` 或通过特殊 yaku 的 `force_cap true` 表示封顶。
|
| 128 |
+
|
| 129 |
+
5. **游戏阶段与玩家操作**
|
| 130 |
+
- 至少包含以下阶段中的一部分:
|
| 131 |
+
- `choose_que`(定缺);
|
| 132 |
+
- `exchange_three`(换三张);
|
| 133 |
+
- `play`(行牌);
|
| 134 |
+
- `settle`(结算)。
|
| 135 |
+
- 需要在 mGDL 的 `phases` 和 `setup` 中显式描述,并在文案中说明每个阶段:
|
| 136 |
+
- 何时开始;
|
| 137 |
+
- 玩家有哪些合法动作;
|
| 138 |
+
- 阶段如何结束。
|
| 139 |
+
- 如果某些阶段不启用(例如血流类一般不换三张),要显式 `(enabled false)` 并在文案中写明“不启用某阶段”。
|
| 140 |
+
|
| 141 |
+
6. **平衡性要求**
|
| 142 |
+
- 通过以下方式论证玩法不会过于偏向某一类手牌或某一种打发:
|
| 143 |
+
- 起胡倍数 / 封顶倍数的选取;
|
| 144 |
+
- 大牌番型的出现概率 vs 倍数收益;
|
| 145 |
+
- 赖子数量:例如只用幺鸡、不加红中,或红中数量=6/8;
|
| 146 |
+
- 血战 vs 血流的节奏差异。
|
| 147 |
+
- 可以用区间描述,如“普通番型(1–4 倍)占多数,大牌型(100 倍以上)出现频率较低,适合作为高风险高收益”。
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
### 输出要求
|
| 152 |
+
|
| 153 |
+
你的回答必须按以下结构组织:
|
| 154 |
+
|
| 155 |
+
1. **玩法名称与理念**
|
| 156 |
+
- 1–3 段自然语言描述。
|
| 157 |
+
|
| 158 |
+
2. **mGDL 规则代码**
|
| 159 |
+
- 使用代码块(```)包裹完整的 `(game "...")` 定义。
|
| 160 |
+
- 必须仅使用通用语法和三种示例中已有的字段和结构,不要引入新概念字段。
|
| 161 |
+
- 每个关键段落(tileset / actions / setup / win_rules / scoring / fan_table / settlement / wildcard 等)添加简短中文注释,说明其作用。
|
| 162 |
+
|
| 163 |
+
3. **自然语言规则说明**
|
| 164 |
+
- 以玩家视角,按阶段拆解:
|
| 165 |
+
- 准备阶段(定缺、换三张、起手牌数等);
|
| 166 |
+
- 对战阶段(行牌顺序、吃/碰/杠/胡的条件);
|
| 167 |
+
- 结算阶段(胡牌、自摸/点炮结算、查花猪/查大叫等)。
|
| 168 |
+
- “番型与倍数”用表格或分条列出,并说明是否叠加、封顶方式。
|
| 169 |
+
- 明确说明:
|
| 170 |
+
- 胡后是否继续 / 已胡玩家是否退出;
|
| 171 |
+
- 每局何时结束;
|
| 172 |
+
- 自摸与点炮的倍率区别;
|
| 173 |
+
- 特殊机制(幺鸡/红中)在计分上的影响。
|
| 174 |
+
|
| 175 |
+
4. **平衡性分析**
|
| 176 |
+
- 简要从以下角度分析:
|
| 177 |
+
- 典型一局的得分范围;
|
| 178 |
+
- 赖子/大牌型对节奏的影响;
|
| 179 |
+
- 是否鼓励积极进攻或稳健防守;
|
| 180 |
+
- 与现有三种玩法相比的差异和可能的优势/风险。
|
| 181 |
+
|
| 182 |
+
5. **规则自检清单**
|
| 183 |
+
- 列出一个清单,并针对每一项用“是/否”自查,格式示例:
|
| 184 |
+
- [ ] 已显式设置 `allow_chi/allow_peng/allow_gang/allow_discard_win/allow_rob_kong/allow_multi_win`(mGDL 中有具体 true/false)。
|
| 185 |
+
- [ ] 已在 `post_win_continuation` 中明确胡后继续规则(血战/血流或自定义)。
|
| 186 |
+
- [ ] 已在 scoring 中选择并说明一种计分体系,包含自摸/点炮倍率、起胡和封顶。
|
| 187 |
+
- [ ] 所有番型在 `fan_table` 中都有��确的 fan/mult 数值,且文案中有对应说明。
|
| 188 |
+
- [ ] 特殊赖子(幺鸡/红中)在 mGDL 中有配置,在文案中有解释,并与计分规则一致。
|
| 189 |
+
- [ ] 未使用“视情况”“略有调整”“类似 XX 玩法”这类模糊表达。
|
| 190 |
+
- [ ] 文案描述与 mGDL 中的布尔/枚举/数值不冲突。
|
| 191 |
+
|
| 192 |
+
请严格遵守以上约束,确保生成的 mGDL 规则在形式上合法、在含义上清晰、在自然语言说明中可被玩家无歧义理解。
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==4.44.1
|
| 2 |
+
openai>=1.0.0
|
security.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
安全模块 - 提供输入验证和安全检查功能
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
from typing import List, Tuple, Optional
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SecurityValidator:
|
| 10 |
+
"""安全验证器"""
|
| 11 |
+
|
| 12 |
+
# 危险文件扩展名
|
| 13 |
+
DANGEROUS_EXTENSIONS = {
|
| 14 |
+
'.exe', '.bat', '.cmd', '.com', '.pif', '.scr', '.vbs', '.js', '.jar',
|
| 15 |
+
'.php', '.asp', '.aspx', '.jsp', '.py', '.pl', '.sh', '.ps1'
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
# 最大文件大小(字节)
|
| 19 |
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 20 |
+
|
| 21 |
+
# 最大文件名长度
|
| 22 |
+
MAX_FILENAME_LENGTH = 255
|
| 23 |
+
|
| 24 |
+
# 危险关键词模式
|
| 25 |
+
DANGEROUS_PATTERNS = [
|
| 26 |
+
r'<script[^>]*>.*?</script>',
|
| 27 |
+
r'javascript:',
|
| 28 |
+
r'data:text/html',
|
| 29 |
+
r'vbscript:',
|
| 30 |
+
r'onload\s*=',
|
| 31 |
+
r'onerror\s*=',
|
| 32 |
+
r'eval\s*\(',
|
| 33 |
+
r'exec\s*\(',
|
| 34 |
+
r'system\s*\(',
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
@staticmethod
|
| 38 |
+
def _is_cjk(char: str) -> bool:
|
| 39 |
+
"""
|
| 40 |
+
判断字符是否为常见东亚字符(中文、日文、韩文、全角符号等)
|
| 41 |
+
"""
|
| 42 |
+
return re.match(r"[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u30ff\uac00-\ud7af]", char) is not None
|
| 43 |
+
|
| 44 |
+
@classmethod
|
| 45 |
+
def validate_file_upload(cls, file_path: str) -> Tuple[bool, str]:
|
| 46 |
+
"""
|
| 47 |
+
验证文件上传的安全性
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
file_path: 文件路径
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
(is_valid, error_message)
|
| 54 |
+
"""
|
| 55 |
+
try:
|
| 56 |
+
# 检查文件是否存在
|
| 57 |
+
if not os.path.exists(file_path):
|
| 58 |
+
return False, "文件不存在"
|
| 59 |
+
|
| 60 |
+
# 检查文件大小
|
| 61 |
+
file_size = os.path.getsize(file_path)
|
| 62 |
+
if file_size > cls.MAX_FILE_SIZE:
|
| 63 |
+
return False, f"文件过大 ({file_size} bytes, 最大: {cls.MAX_FILE_SIZE})"
|
| 64 |
+
|
| 65 |
+
# 检查文件名
|
| 66 |
+
filename = os.path.basename(file_path)
|
| 67 |
+
if len(filename) > cls.MAX_FILENAME_LENGTH:
|
| 68 |
+
return False, f"文件名过长 ({len(filename)} 字符, 最大: {cls.MAX_FILENAME_LENGTH})"
|
| 69 |
+
|
| 70 |
+
# 检查文件扩展名
|
| 71 |
+
_, ext = os.path.splitext(filename.lower())
|
| 72 |
+
if ext in cls.DANGEROUS_EXTENSIONS:
|
| 73 |
+
return False, f"不支持的文件类型: {ext}"
|
| 74 |
+
|
| 75 |
+
# 检查文件名中的危险字符
|
| 76 |
+
if not cls._is_safe_filename(filename):
|
| 77 |
+
return False, "文件名包含危险字符"
|
| 78 |
+
|
| 79 |
+
return True, ""
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return False, f"文件验证失败: {str(e)}"
|
| 83 |
+
|
| 84 |
+
@classmethod
|
| 85 |
+
def validate_text_input(cls, text: str, max_length: int = 10000) -> Tuple[bool, str]:
|
| 86 |
+
"""
|
| 87 |
+
验证文本输入的安全性
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
text: 输入文本
|
| 91 |
+
max_length: 最大长度
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
(is_valid, error_message)
|
| 95 |
+
"""
|
| 96 |
+
if not text:
|
| 97 |
+
return True, ""
|
| 98 |
+
|
| 99 |
+
# 检查长度
|
| 100 |
+
if len(text) > max_length:
|
| 101 |
+
return False, f"输入过长 ({len(text)} 字符, 最大: {max_length})"
|
| 102 |
+
|
| 103 |
+
# 检查危险模式
|
| 104 |
+
for pattern in cls.DANGEROUS_PATTERNS:
|
| 105 |
+
if re.search(pattern, text, re.IGNORECASE | re.DOTALL):
|
| 106 |
+
return False, "输入包含潜在危险内容"
|
| 107 |
+
|
| 108 |
+
# 检查非常规字符比例(放宽中文/日文/韩文及常见标点)
|
| 109 |
+
def _is_safe_char(c: str) -> bool:
|
| 110 |
+
if not c:
|
| 111 |
+
return True
|
| 112 |
+
# 字母数字与空白
|
| 113 |
+
if c.isalnum() or c.isspace():
|
| 114 |
+
return True
|
| 115 |
+
# 常见中英文标点
|
| 116 |
+
if re.match(r"[\.,;:!\?\-_'\"(){}\[\]\\/@#%&\+=<>~\^\|$,。;:!?、()【】《》—…·]", c):
|
| 117 |
+
return True
|
| 118 |
+
# CJK 字符与全角符号
|
| 119 |
+
if SecurityValidator._is_cjk(c):
|
| 120 |
+
return True
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
total_len = len(text)
|
| 124 |
+
if total_len > 0:
|
| 125 |
+
unsafe_count = sum(1 for ch in text if not _is_safe_char(ch))
|
| 126 |
+
# 只有当非常规字符比例很高时才判为风险(阈值 0.5)
|
| 127 |
+
if unsafe_count / total_len > 0.5:
|
| 128 |
+
return False, "输入包含大量非常规字符"
|
| 129 |
+
|
| 130 |
+
return True, ""
|
| 131 |
+
|
| 132 |
+
@classmethod
|
| 133 |
+
def sanitize_filename(cls, filename: str) -> str:
|
| 134 |
+
"""
|
| 135 |
+
清理文件名,移除危险字符
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
filename: 原始文件名
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
清理后的文件名
|
| 142 |
+
"""
|
| 143 |
+
# 移除路径分隔符和危险字符
|
| 144 |
+
dangerous_chars = r'[<>:"/\\|?*\x00-\x1f]'
|
| 145 |
+
sanitized = re.sub(dangerous_chars, '_', filename)
|
| 146 |
+
|
| 147 |
+
# 限制长度
|
| 148 |
+
if len(sanitized) > cls.MAX_FILENAME_LENGTH:
|
| 149 |
+
name, ext = os.path.splitext(sanitized)
|
| 150 |
+
max_name_length = cls.MAX_FILENAME_LENGTH - len(ext)
|
| 151 |
+
sanitized = name[:max_name_length] + ext
|
| 152 |
+
|
| 153 |
+
return sanitized
|
| 154 |
+
|
| 155 |
+
@classmethod
|
| 156 |
+
def _is_safe_filename(cls, filename: str) -> bool:
|
| 157 |
+
"""
|
| 158 |
+
检查文件名是否安全
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
filename: 文件名
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
是否安全
|
| 165 |
+
"""
|
| 166 |
+
# 检查危险字符
|
| 167 |
+
dangerous_chars = r'[<>:"/\\|?*\x00-\x1f]'
|
| 168 |
+
if re.search(dangerous_chars, filename):
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
# 检查保留名称(Windows)
|
| 172 |
+
reserved_names = {
|
| 173 |
+
'CON', 'PRN', 'AUX', 'NUL',
|
| 174 |
+
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
| 175 |
+
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
name_without_ext = os.path.splitext(filename)[0].upper()
|
| 179 |
+
if name_without_ext in reserved_names:
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
return True
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class InputValidator:
|
| 186 |
+
"""输入验证器"""
|
| 187 |
+
|
| 188 |
+
@staticmethod
|
| 189 |
+
def validate_message(message: str) -> Tuple[bool, str]:
|
| 190 |
+
"""
|
| 191 |
+
验证用户消息
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
message: 用户消息
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
(is_valid, error_message)
|
| 198 |
+
"""
|
| 199 |
+
if not message or not message.strip():
|
| 200 |
+
return False, "消息不能为空"
|
| 201 |
+
|
| 202 |
+
# 放宽消息长度上限(本地校验)
|
| 203 |
+
return SecurityValidator.validate_text_input(message, max_length=200000)
|
| 204 |
+
|
| 205 |
+
@staticmethod
|
| 206 |
+
def validate_custom_prompt(prompt: str) -> Tuple[bool, str]:
|
| 207 |
+
"""
|
| 208 |
+
验证自定义提示词
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
prompt: 自定义提示词
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
(is_valid, error_message)
|
| 215 |
+
"""
|
| 216 |
+
if not prompt:
|
| 217 |
+
return True, ""
|
| 218 |
+
|
| 219 |
+
# 放宽自定义提示词长度上限(本地校验)
|
| 220 |
+
return SecurityValidator.validate_text_input(prompt, max_length=200000)
|
| 221 |
+
|
| 222 |
+
@staticmethod
|
| 223 |
+
def validate_file_list(files: List) -> Tuple[bool, str]:
|
| 224 |
+
"""
|
| 225 |
+
验证文件列表
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
files: 文件列表
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
(is_valid, error_message)
|
| 232 |
+
"""
|
| 233 |
+
if not files:
|
| 234 |
+
return True, ""
|
| 235 |
+
|
| 236 |
+
file_list = files if isinstance(files, (list, tuple)) else [files]
|
| 237 |
+
|
| 238 |
+
for file_obj in file_list:
|
| 239 |
+
file_path = getattr(file_obj, "name", None) or str(file_obj)
|
| 240 |
+
is_valid, error_msg = SecurityValidator.validate_file_upload(file_path)
|
| 241 |
+
if not is_valid:
|
| 242 |
+
return False, error_msg
|
| 243 |
+
|
| 244 |
+
return True, ""
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# 全局验证器实例
|
| 248 |
+
security_validator = SecurityValidator()
|
| 249 |
+
input_validator = InputValidator()
|
styles.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
样式文件 - 集中管理 CSS 样式 (强制深色主题)
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
MAHJONG_THEME_CSS = """
|
| 6 |
+
html, body{ background-color:#0b1220 !important; color-scheme: dark !important; }
|
| 7 |
+
/* ===== 仅添加强制深色主题的关键代码 ===== */
|
| 8 |
+
:root {
|
| 9 |
+
color-scheme: dark !important;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
@media (prefers-color-scheme: light) {
|
| 13 |
+
:root {
|
| 14 |
+
color-scheme: dark !important;
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* 修复HuggingFace可能添加的滤镜效果 */
|
| 19 |
+
.gradio-container {
|
| 20 |
+
filter: none !important;
|
| 21 |
+
-webkit-filter: none !important;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* ===== Mahjong 主题配色 ===== */
|
| 25 |
+
:root{
|
| 26 |
+
--felt:#0f5132; /* 青 jade */
|
| 27 |
+
--felt-2:#09301d;
|
| 28 |
+
--felt-light:#1c7d4f;
|
| 29 |
+
--wood:#5e4430;
|
| 30 |
+
--wood-2:#3f2f21;
|
| 31 |
+
--paper:#f8f3e7;
|
| 32 |
+
--ink:#f3f4f6;
|
| 33 |
+
--muted:#cbd5e1;
|
| 34 |
+
--accent: #ffcc4d;
|
| 35 |
+
|
| 36 |
+
/* HERO 渐变(青→竹→朱) */
|
| 37 |
+
--hero-left:#041b16;
|
| 38 |
+
--hero-mid:#0c6244;
|
| 39 |
+
--hero-right:#b7452f;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
body, .gradio-container{
|
| 43 |
+
background: radial-gradient(1200px 800px at 15% -20%, #143828 0%, #0a1f18 45%, #050c0a 100%) !important;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* 顶部 HERO:左深右浅的线性渐变,白字清晰 */
|
| 47 |
+
.hero{
|
| 48 |
+
position:relative;
|
| 49 |
+
background: linear-gradient(90deg, var(--hero-left) 0%, var(--hero-mid) 48%, var(--hero-right) 100%);
|
| 50 |
+
color:#fff; /* 固定白字 */
|
| 51 |
+
text-shadow: 0 2px 6px rgba(0,0,0,.35);
|
| 52 |
+
border-radius:20px;
|
| 53 |
+
padding:18px 16px;
|
| 54 |
+
box-shadow: 0 10px 28px rgba(0,0,0,.25), inset 0 -3px 0 rgba(255,255,255,.25);
|
| 55 |
+
margin-bottom: 10px;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* Hero 右上角麻将牌组 */
|
| 59 |
+
.hero-icons{
|
| 60 |
+
position:absolute;
|
| 61 |
+
top:12px;
|
| 62 |
+
right:18px;
|
| 63 |
+
display:flex;
|
| 64 |
+
gap:8px;
|
| 65 |
+
}
|
| 66 |
+
.hero-icons .hero-photo{
|
| 67 |
+
width:40px;
|
| 68 |
+
height:50px;
|
| 69 |
+
border-radius:8px;
|
| 70 |
+
background: linear-gradient(180deg, #fdf9f0 0%, #efe4cf 55%, #d9c4a3 100%);
|
| 71 |
+
box-shadow: 0 4px 0 rgba(0,0,0,.35), inset 0 0 0 1px rgba(255,255,255,.8);
|
| 72 |
+
border:1px solid rgba(0,0,0,.28);
|
| 73 |
+
background-color:#fdf9f0;
|
| 74 |
+
background-size:cover;
|
| 75 |
+
background-position:center;
|
| 76 |
+
background-repeat:no-repeat;
|
| 77 |
+
}
|
| 78 |
+
/* 左侧设置卡片(半透明) */
|
| 79 |
+
.side-card{
|
| 80 |
+
background: rgba(255,255,255,.06);
|
| 81 |
+
border: 1px solid rgba(255,255,255,.12);
|
| 82 |
+
border-radius:16px; padding:14px;
|
| 83 |
+
box-shadow: 0 4px 14px rgba(0,0,0,.18);
|
| 84 |
+
backdrop-filter: blur(3px);
|
| 85 |
+
color:var(--ink);
|
| 86 |
+
}
|
| 87 |
+
.side-card h3{ margin:0 0 8px 0; }
|
| 88 |
+
|
| 89 |
+
/* 麻将桌容器:木框 + 翡翠台呢 + 竹纹 */
|
| 90 |
+
.table{
|
| 91 |
+
position:relative;
|
| 92 |
+
background:
|
| 93 |
+
radial-gradient(900px 600px at 35% 10%, rgba(255,255,255,.08) 0%, transparent 60%),
|
| 94 |
+
radial-gradient(1200px 800px at 35% 10%, var(--felt) 0%, var(--felt-2) 60%, #041912 100%);
|
| 95 |
+
border-radius:22px;
|
| 96 |
+
border: 12px solid transparent;
|
| 97 |
+
background-clip: padding-box;
|
| 98 |
+
box-shadow:
|
| 99 |
+
inset 0 0 50px rgba(0,0,0,.35),
|
| 100 |
+
0 20px 60px rgba(0,0,0,.35);
|
| 101 |
+
}
|
| 102 |
+
.table:before{ /* 木质外框 */
|
| 103 |
+
content:"";
|
| 104 |
+
position:absolute; inset:-14px;
|
| 105 |
+
border-radius:28px;
|
| 106 |
+
background: linear-gradient(90deg, var(--wood), var(--wood-2));
|
| 107 |
+
z-index:-1;
|
| 108 |
+
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 8px rgba(255,255,255,.1);
|
| 109 |
+
}
|
| 110 |
+
.table:after{ /* 低透竹纹 */
|
| 111 |
+
content:"";
|
| 112 |
+
position:absolute; inset:0;
|
| 113 |
+
background-image: linear-gradient(120deg, rgba(255,255,255,.05) 15%, transparent 30%),
|
| 114 |
+
linear-gradient(300deg, rgba(0,0,0,.12) 20%, transparent 40%);
|
| 115 |
+
background-size: 140px 140px;
|
| 116 |
+
pointer-events:none;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* 聊天组件细节:头像更小、气泡更易读 */
|
| 120 |
+
.custom-chatbot .message{ font-size:16px; line-height:1.6; color:var(--ink); word-break:break-word; }
|
| 121 |
+
.custom-chatbot .message.user{ background: rgba(255,255,255,.08) !important; }
|
| 122 |
+
.custom-chatbot .message.bot{ background: rgba(0,0,0,.20) !important; }
|
| 123 |
+
|
| 124 |
+
/* 按钮:麻将牌风格 */
|
| 125 |
+
.gradio-container button.gr-button{
|
| 126 |
+
border:2px solid rgba(255,255,255,.35);
|
| 127 |
+
color:#193c2a;
|
| 128 |
+
font-weight:700;
|
| 129 |
+
letter-spacing:.3px;
|
| 130 |
+
background: linear-gradient(180deg, var(--paper) 0%, #efe6d2 60%, #dccdb0 100%);
|
| 131 |
+
border-radius:18px;
|
| 132 |
+
box-shadow: 0 6px 0 rgba(0,0,0,.35), inset 0 0 0 2px rgba(255,255,255,.6);
|
| 133 |
+
padding:10px 20px;
|
| 134 |
+
transition: transform .08s ease, box-shadow .08s ease;
|
| 135 |
+
}
|
| 136 |
+
.gradio-container button.gr-button:hover{ transform: translateY(-1px); }
|
| 137 |
+
.gradio-container button.gr-button:active{
|
| 138 |
+
transform: translateY(2px);
|
| 139 |
+
box-shadow: 0 3px 0 rgba(0,0,0,.45), inset 0 0 0 2px rgba(255,255,255,.6);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* 输入框在桌面里更融洽 */
|
| 143 |
+
.table .gr-textbox textarea{
|
| 144 |
+
background: rgba(0,0,0,.20) !important; color:#e5e7eb !important;
|
| 145 |
+
border-radius:12px !important;
|
| 146 |
+
border:1px solid rgba(255,255,255,.18) !important;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/* 小提示文字颜色 */
|
| 150 |
+
.hint{ color:var(--muted); font-size:12px; }
|
| 151 |
+
|
| 152 |
+
@media (max-width: 820px){
|
| 153 |
+
.custom-chatbot .avatar,
|
| 154 |
+
.custom-chatbot .message .avatar{
|
| 155 |
+
width: 62px !important;
|
| 156 |
+
height: 62px !important;
|
| 157 |
+
}
|
| 158 |
+
.custom-chatbot .avatar-image{ width:58px !important; height:58px !important; }
|
| 159 |
+
}
|
| 160 |
+
/* === 修复 ChatInterface 内嵌发送/停止按钮在深色主题下不可见的问题 === */
|
| 161 |
+
:where(.gradio-container) button[aria-label="submit"],
|
| 162 |
+
:where(.gradio-container) button[aria-label="stop"]{
|
| 163 |
+
display: inline-flex !important;
|
| 164 |
+
align-items: center;
|
| 165 |
+
justify-content: center;
|
| 166 |
+
opacity: 1 !important;
|
| 167 |
+
visibility: visible !important;
|
| 168 |
+
pointer-events: auto !important;
|
| 169 |
+
border: 1px solid rgba(255,255,255,.22) !important;
|
| 170 |
+
background: rgba(255,255,255,.09) !important;
|
| 171 |
+
color: #fff !important;
|
| 172 |
+
border-radius: 10px !important;
|
| 173 |
+
height: 36px !important;
|
| 174 |
+
width: 36px !important;
|
| 175 |
+
cursor: pointer !important;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* 保证输入框容器能正确容纳右侧内嵌按钮 */
|
| 179 |
+
:where(.gradio-container) .gr-chat-interface .gr-textbox{
|
| 180 |
+
position: relative;
|
| 181 |
+
}
|
| 182 |
+
:where(.gradio-container) .gr-chat-interface .gr-textbox textarea{
|
| 183 |
+
padding-right: 48px !important;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/* === Chatbot 头像尺寸 & 对齐(稳定版)=== */
|
| 187 |
+
/* 放大 Chatbot 头像(统一设置) */
|
| 188 |
+
.custom-chatbot .avatar,
|
| 189 |
+
.custom-chatbot .message .avatar{
|
| 190 |
+
width: 88px !important;
|
| 191 |
+
height: 88px !important;
|
| 192 |
+
min-width: 88px !important;
|
| 193 |
+
min-height: 88px !important;
|
| 194 |
+
border-radius: 9999px !important;
|
| 195 |
+
overflow: hidden !important;
|
| 196 |
+
border: 2px solid #ffffffcc !important;
|
| 197 |
+
box-shadow: 0 2px 6px rgba(0,0,0,.25) !important;
|
| 198 |
+
flex-shrink: 0 !important;
|
| 199 |
+
align-self: flex-start !important;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/* 控制头像尺寸(命中 gradio v4/5 的 DOM 结构) */
|
| 203 |
+
.custom-chatbot .avatar-image {
|
| 204 |
+
width: 82px !important;
|
| 205 |
+
height: 82px !important;
|
| 206 |
+
border-radius: 50% !important; /* 保持圆形 */
|
| 207 |
+
object-fit: cover !important;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/* 可选:调整气泡和头像的间距 */
|
| 211 |
+
.custom-chatbot .message {
|
| 212 |
+
gap: 12px !important; /* 改成更大/更小 */
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
/* 头像图片填充容器,不变形 */
|
| 218 |
+
.custom-chatbot .avatar > img,
|
| 219 |
+
.custom-chatbot .avatar-image{
|
| 220 |
+
width: 100% !important;
|
| 221 |
+
height: 100% !important;
|
| 222 |
+
object-fit: cover !important;
|
| 223 |
+
object-position: 50% 50% !important;
|
| 224 |
+
display: block !important;
|
| 225 |
+
border: 0 !important;
|
| 226 |
+
background: transparent !important;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/* 头像与气泡的间距与对齐(可选) */
|
| 230 |
+
.custom-chatbot .message{ gap: 12px !important; align-items: flex-start !important; }
|
| 231 |
+
.custom-chatbot .message .message-content{ margin-top: 2px !important; }
|
| 232 |
+
"""
|