import gradio as gr
import os
# ===== 你的自定义模块 =====
from config import APP_TITLE, CHATBOT_HEIGHT, MIN_WIDTH_LEFT, MIN_WIDTH_RIGHT
from styles import POKER_THEME_CSS
# Extra CSS to force dark & counter browser auto-invert
EXTRA_FIX_CSS = '''/* === Force dark site and neutralize browser-side auto-invert === */
html, body{ background-color:#0b1220 !important; color-scheme: dark !important; }
/* When browser/extension applies global invert, add a counter-invert on our app root */
html.force-dark-fix #app-root{ filter: invert(1) hue-rotate(180deg) !important; }'''
# Redirect to ?__theme=dark
FORCE_DARK_REDIRECT = ''''''
# Detect browser force-dark and set html class
DETECT_FORCE_DARK = ''''''
from ai_service import design_poker_game
# 优先尝试原生流式;没有则自动回退为伪流式
try:
from ai_service import design_poker_game_stream
except Exception:
design_poker_game_stream = None
# ====== 🔧 Hotfix: 兼容 gradio_client 对 additionalProperties(bool) 的解析 ======
try:
import gradio_client.utils as _gc_utils
_orig_get_type = _gc_utils.get_type
def _safe_get_type(schema):
if isinstance(schema, bool):
return "any" if schema else "never"
return _orig_get_type(schema)
_gc_utils.get_type = _safe_get_type
_orig_json2py = _gc_utils._json_schema_to_python_type
def _safe_json2py(schema, defs):
if isinstance(schema, bool):
return "any" if schema else "never"
if isinstance(schema, dict) and "additionalProperties" in schema:
ap = schema["additionalProperties"]
if isinstance(ap, bool):
inner = "any" if ap else "never"
return f"dict[str, {inner}]"
return _orig_json2py(schema, defs)
_gc_utils._json_schema_to_python_type = _safe_json2py
_orig_json_schema_to_python_type = _gc_utils.json_schema_to_python_type
def _safe_json_schema_to_python_type(schema):
if isinstance(schema, bool):
return "any" if schema else "never"
return _orig_json_schema_to_python_type(schema)
_gc_utils.json_schema_to_python_type = _safe_json_schema_to_python_type
except Exception:
pass
# ====== 🔧 Hotfix 结束 ======
# ==================== 工具函数 ====================
def _messages_to_tuples(history):
"""
将 Chatbot 的 messages 格式([{role, content}, ...])转换为 [(user, bot), ...]。
若已是 tuples,则原样返回。
"""
if not history:
return []
if isinstance(history, list) and history and isinstance(history[0], dict):
pairs = []
last_user = None
for msg in history:
role = msg.get("role")
content = msg.get("content", "")
if role == "user":
last_user = content
elif role == "assistant":
pairs.append((last_user or "", content))
last_user = None
return pairs
return history
def _chunk_fake_stream(text, step=40):
"""把整段文本切成小块,伪流式输出。"""
s = str(text or "")
for i in range(0, len(s), step):
yield s[i:i + step]
def clear_cache():
"""清空缓存"""
from cache_manager import file_cache, request_cache
file_cache.clear()
request_cache.clear()
return "✅ 缓存已清空"
def clear_files():
"""清空文件上传"""
return None
def update_file_status(files):
"""更新文件状态显示"""
if not files:
return "📁 文件状态:未上传"
file_list = files if isinstance(files, (list, tuple)) else [files]
names = []
for f in file_list:
if isinstance(f, str):
names.append(os.path.basename(f))
else:
names.append(os.path.basename(getattr(f, "name", str(f))))
head = "\n".join(f" • {n}" for n in names[:3])
tail = "\n ..." if len(names) > 3 else ""
return f"📁 文件状态:已上传 {len(names)} 个文件\n{head}{tail}"
def export_history_to_markdown(history):
"""将聊天历史导出为 Markdown 文件,并返回文件路径供下载"""
import time
import pathlib
from datetime import datetime
pairs = _messages_to_tuples(history)
lines = ["# 对话记录", ""]
lines.append(f"- 导出时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
lines.append("")
for idx, (user_msg, bot_msg) in enumerate(pairs, start=1):
lines.append(f"## 轮次 {idx}")
if user_msg:
lines.append("**用户**:\n")
lines.append(user_msg)
lines.append("")
if bot_msg:
lines.append("**助手**:\n")
lines.append(bot_msg)
lines.append("")
content = "\n".join(lines).strip() + "\n"
export_dir = pathlib.Path("exports")
export_dir.mkdir(parents=True, exist_ok=True)
filename = export_dir / f"chat_history_{int(time.time())}.md"
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
return str(filename)
# ==================== 新增:提取 GDL 和自然语言描述 ====================
def extract_gdl_and_narrative(content):
"""提取 GDL 和自然语言部分(支持多种格式变体)"""
import re
# 定义多种可能的标记格式(按优先级排序,支持更多变体)
gdl_patterns = [
r"##\s*GDL\s*描述", # ## GDL描述 / ## GDL 描述
r"##\s*GDL描述", # ##GDL描述
r"GDL\s*描述", # GDL描述 / GDL 描述
r"##\s*GDL\s*Description", # ## GDL Description(英文)
r"##\s*GDL", # ## GDL
]
narrative_patterns = [
r"##\s*自然语言规则说明", # ## 自然语言规则说明
r"##\s*自然语言规则", # ## 自然语言规则
r"自然语言规则说明", # 自然语言规则说明
r"自然语言规则", # 自然语言规则
r"##\s*规则说明", # ## 规则说明
r"规则说明", # 规则说明
]
# 尝试查找 GDL 部分(支持大小写不敏感)
gdl_start = -1
for pattern in gdl_patterns:
match = re.search(pattern, content, re.IGNORECASE)
if match:
gdl_start = match.start()
break
# 尝试查找自然语言部分(支持大小写不敏感)
narrative_start = -1
for pattern in narrative_patterns:
match = re.search(pattern, content, re.IGNORECASE)
if match:
narrative_start = match.start()
break
if gdl_start != -1 and narrative_start != -1:
# 确保顺序正确(GDL应该在自然语言之前)
if gdl_start >= narrative_start:
gdl_start, narrative_start = narrative_start, gdl_start
# 获取 GDL 部分(从标记开始到自然语言标记之前)
gdl_content = content[gdl_start:narrative_start].strip()
# 获取自然语言部分(从标记开始到结尾)
narrative_content = content[narrative_start:].strip()
return gdl_content, narrative_content
elif gdl_start != -1:
# 只找到GDL,将后面全部作为GDL
print(f"⚠️ 仅找到GDL标记,将其后内容作为GDL")
return content[gdl_start:].strip(), ""
elif narrative_start != -1:
# 只找到自然语言,将后面全部作为自然语言
print(f"⚠️ 仅找到自然语言标记,将其后内容作为自然语言")
return "", content[narrative_start:].strip()
else:
# 都没找到,返回空
print(f"⚠️ 提取警告: 未找到GDL和自然语言标记")
return "", ""
def save_gdl_and_narrative(gdl_content, narrative_content):
"""保存 GDL 和自然语言内容到文件"""
# 定义文件存储路径
export_dir = os.path.join("exports")
os.makedirs(export_dir, exist_ok=True)
gdl_file_path = os.path.join(export_dir, "gdl_output.txt")
narrative_file_path = os.path.join(export_dir, "narrative_output.txt")
# 保存 GDL
with open(gdl_file_path, "w", encoding="utf-8") as f:
f.write(gdl_content)
# 保存自然语言
with open(narrative_file_path, "w", encoding="utf-8") as f:
f.write(narrative_content)
return gdl_file_path, narrative_file_path
# ==================== Gradio 界面(Poker Skin) ====================
with gr.Blocks(
theme=gr.themes.Soft(primary_hue='emerald', neutral_hue='slate'),
title=APP_TITLE,
css=POKER_THEME_CSS + EXTRA_FIX_CSS,
elem_id='app-root'
) as demo:
gr.HTML(FORCE_DARK_REDIRECT)
gr.HTML(DETECT_FORCE_DARK)
# 顶部 Hero
gr.HTML("""
""")
with gr.Row(equal_height=True):
# 左侧:设置区
with gr.Column(scale=1, min_width=MIN_WIDTH_LEFT):
with gr.Group(elem_classes="side-card"):
gr.Markdown("### 输入与约束")
gr.Markdown("✅ **系统已预加载默认 GDL 规范和 Prompt**,您可以直接开始对话!", elem_classes="hint")
file_uploader = gr.File(
label="上传自定义 GDL 规范/示例(可选,.txt,可多选)",
file_types=[".txt"],
file_count="multiple",
type="filepath",
interactive=True,
show_label=True,
container=True,
scale=1
)
custom_prompt_box = gr.Textbox(
label="自定义 System Prompt(可选)",
placeholder="系统已预加载默认提示词。如需自定义,可在此粘贴您的提示词。",
lines=10,
max_lines=20,
show_copy_button=True
)
prompt_mode = gr.Radio(
choices=["覆盖默认SYSTEM_PROMPT", "合并(默认在前)"],
value="覆盖默认SYSTEM_PROMPT",
label="自定义 Prompt 的使用方式"
)
gr.Markdown("💡 提示:系统已自动加载默认 GDL 语法和 Prompt,无需手动上传即可使用。如需自定义,可上传文件或输入自定义 Prompt。", elem_classes="hint")
with gr.Group(elem_classes="side-card"):
gr.Markdown("### 使用说明")
gr.Markdown(
"- ✅ **快速开始**:系统已预加载默认配置,直接在右侧聊天框开始对话即可。\n"
"- 📁 **可选上传**:如需使用自定义GDL规范,可上传自己的.txt文件。\n"
"- ✏️ **可选自定义**:如需修改Prompt,可在上方文本框输入或粘贴。\n"
"- 🔄 **清空缓存**:修改配置后,建议清空缓存以确保新配置生效。"
)
with gr.Group(elem_classes="side-card"):
gr.Markdown("### 快捷操作")
with gr.Row():
clear_cache_btn = gr.Button("清空缓存", variant="secondary")
clear_files_btn = gr.Button("清空文件", variant="secondary")
cache_status = gr.Markdown("💾 缓存状态:正常", elem_classes="hint")
file_status = gr.Markdown("📁 文件状态:使用默认配置(GDL + Prompt 已预加载)", elem_classes="hint")
# 右侧:聊天区(手动事件绑定 + 流式输出)
with gr.Column(scale=2, min_width=MIN_WIDTH_RIGHT):
with gr.Group(elem_classes="table"):
chatbot = gr.Chatbot(
height=CHATBOT_HEIGHT,
type="messages",
elem_classes="custom-chatbot",
avatar_images=("landlord.png", "bot.png"),
)
# 用 State 保存 messages 历史
chat_state = gr.State([]) # list[dict]: [{"role":"user","content":...}, {"role":"assistant","content":...}]
gdl_file_path = gr.State("")
narrative_file_path = gr.State("")
user_input = gr.Textbox(
placeholder="例如:设计一个适合3-5人的派对风格扑克游戏(请写清目标人群/时长/创新点)…",
show_label=False,
max_lines=5,
lines=3,
show_copy_button=True,
autofocus=True,
)
with gr.Row():
send_btn = gr.Button("发送", variant="primary")
stop_info = gr.Markdown("", visible=False)
# ====== 核心:流式提交回调(生成器) ======
def on_submit(user_text, history_msgs, files, custom_prompt, mode):
user_text = (user_text or "").strip()
if not user_text:
# 不提交空消息:输出不变
yield history_msgs, "", history_msgs, "", "" # 🟩【修改】输出数量改为5个
return
# 立即显示“用户消息”
history = list(history_msgs or [])
history.append({"role": "user", "content": user_text})
yield history, "", history, "", ""
# 添加空的助手气泡,用于逐步填充
history.append({"role": "assistant", "content": ""})
yield history, "", history, "", ""
# 流式生成内容
tuples_hist = _messages_to_tuples(history)
if design_poker_game_stream is not None:
try:
for piece in design_poker_game_stream(user_text, tuples_hist, files, custom_prompt, mode):
if not piece:
continue
history[-1]["content"] += str(piece)
yield history, "", history, "", ""
except Exception as e:
history[-1]["content"] += f"\n(流式出错){type(e).__name__}: {e}"
yield history, "", history, "", ""
else:
try:
full = design_poker_game(user_text, tuples_hist, files, custom_prompt, mode)
except Exception as e:
full = f"(出错){type(e).__name__}: {e}"
for piece in _chunk_fake_stream(str(full), step=40):
history[-1]["content"] += piece
yield history, "", history, "", ""
# 提取 GDL 和自然语言描述并保存
try:
gdl_content, narrative_content = extract_gdl_and_narrative(history[-1]["content"])
# 保存 GDL 和自然语言文件
gdl_path, narrative_path = save_gdl_and_narrative(gdl_content, narrative_content)
# 返回文件路径,以便下载
yield history, "", history, gdl_path, narrative_path # 🟩【修改】返回文件路径
except Exception as e:
print(f"保存GDL和自然语言文件时出错: {e}")
yield history, "", history, "", "" # 🟩【修改】返回空路径
# 绑定:回车提交(Enter=提交;Shift+Enter=换行由浏览器处理)
user_input.submit(
fn=on_submit,
inputs=[user_input, chat_state, file_uploader, custom_prompt_box, prompt_mode],
outputs=[chatbot, user_input, chat_state, gdl_file_path, narrative_file_path], # 🟩【修改】输出改为5个
preprocess=True,
)
# 绑定:点击"发送"
send_btn.click(
fn=on_submit,
inputs=[user_input, chat_state, file_uploader, custom_prompt_box, prompt_mode],
outputs=[chatbot, user_input, chat_state, gdl_file_path, narrative_file_path], # 🟩【修改】输出改为5个
preprocess=True,
)
# 导出对话
with gr.Row():
export_btn = gr.Button("导出对话(Markdown)", variant="secondary")
export_file = gr.File(label="点击下载导出文件", interactive=False)
# 清空对话
with gr.Row():
clear_dialog_btn = gr.Button("清空对话", variant="secondary")
def _clear_chat():
return [], "", [], "", ""
clear_dialog_btn.click(
fn=_clear_chat,
inputs=None,
outputs=[chatbot, user_input, chat_state, gdl_file_path, narrative_file_path], # 🟩【修改】输出改为5个
)
# ==================== 下载按钮部分 ====================
with gr.Row():
gr.Markdown("### 下载 GDL 和自然语言描述")
download_gdl_btn = gr.Button("下载 GDL 文件", variant="secondary") # 下载 GDL 按钮
download_narrative_btn = gr.Button("下载自然语言文件", variant="secondary") # 下载自然语言按钮
download_gdl_file = gr.File(label="GDL 文件", interactive=False) # 文件下载区域
download_narrative_file = gr.File(label="自然语言文件", interactive=False) # 文件下载区域
# 🟩【修改】绑定下载按钮与文件路径 - 修复版本
def get_gdl_file(gdl_path):
if gdl_path and os.path.exists(gdl_path):
return gdl_path
return None
def get_narrative_file(narrative_path):
if narrative_path and os.path.exists(narrative_path):
return narrative_path
return None
# 绑定下载按钮与文件路径
download_gdl_btn.click(
fn=get_gdl_file,
inputs=[gdl_file_path], # 🟩【修改】接收State中的路径
outputs=[download_gdl_file]
)
download_narrative_btn.click(
fn=get_narrative_file,
inputs=[narrative_file_path], # 🟩【修改】接收State中的路径
outputs=[download_narrative_file]
)
# ==================== 事件绑定(左侧) ====================
clear_cache_btn.click(fn=clear_cache, outputs=[cache_status])
clear_files_btn.click(fn=clear_files, outputs=[file_uploader])
file_uploader.change(fn=update_file_status, inputs=[file_uploader], outputs=[file_status])
# 导出按钮
def _export_wrapper(chat_history):
try:
path = export_history_to_markdown(chat_history)
return path
except Exception as e:
import tempfile
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".md")
with open(tmp.name, 'w', encoding='utf-8') as f:
f.write(f"导出失败:{type(e).__name__}: {e}\n")
return tmp.name
export_btn.click(fn=_export_wrapper, inputs=[chatbot], outputs=[export_file])
# ==================== 启动应用 ====================
if __name__ == "__main__":
# 兼容不同 gradio 版本:
# - 有的版本 queue() 不接受 concurrency_count/status_update_rate
# - 有的版本甚至不需要手动 queue()
app = demo
try:
app = demo.queue() # 不带参数,启用队列以支持生成器流式
except TypeError:
# 某些版本 queue() 可能参数或签名不同,直接跳过即可
app = demo
app.launch(share=True, show_api=False)