Estazz commited on
Commit
a705843
·
verified ·
1 Parent(s): 7756c57

Upload 20 files

Browse files
%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
+ """