dw112/PocketLLM
0
1import random2import re3import json4import os5import base646from threading import Thread7from datetime import datetime8 9import torch10import numpy as np11import streamlit as st12from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer13 14st.set_page_config(page_title="PocketLLM", initial_sidebar_state="expanded", layout="wide")15 16# ================= 本地图片转 Base64 =================17def get_image_base64(filename):18 img_dir = "/app/images"19 path = os.path.join(img_dir, filename)20 if os.path.exists(path):21 with open(path, "rb") as f:22 return f"data:image/png;base64,{base64.b64encode(f.read()).decode()}"23 # 占位透明图24 return "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"25 26logo_b64 = get_image_base64("logo.png")27banner_b64 = get_image_base64("顶部横幅.png")28avatar_b64 = get_image_base64("助手头像.png")29# =================================================30 31st.markdown("""32 <style>33 /* 全局字体 (Gemini 风格) */34 @import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap');35 html, body, [class*="css"] {36 font-family: 'Google Sans', 'Noto Sans SC', sans-serif !important;37 color: #1f1f1f;38 }39 .stMainBlockContainer {40 padding-top: 1rem !important;41 padding-bottom: 5rem !important;42 }43 /* 历史对话按钮模拟 */44 .history-btn {45 background: transparent;46 border: none;47 color: #444;48 padding: 10px 15px;49 width: 100%;50 text-align: left;51 border-radius: 8px;52 cursor: pointer;53 font-size: 14px;54 margin-bottom: 5px;55 transition: background 0.2s;56 }57 .history-btn:hover {58 background: #f0f4f9;59 }60 /* 滚动条 */61 ::-webkit-scrollbar { width: 6px; height: 6px; }62 ::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }63 ::-webkit-scrollbar-track { background: transparent; }64 </style>65""", unsafe_allow_html=True)66 67device = "cuda" if torch.cuda.is_available() else "cpu"68 69# ================= 多语言文本 =================70LANG_TEXTS = {71 'zh': {72 'settings': '模型设定调整',73 'history_rounds': '历史对话轮次',74 'max_length': '最大生成长度',75 'temperature': '温度',76 'thinking': '✨ 开启深度思考',77 'tools': '工具选择 (最多4个)',78 'language': '语言',79 'send': '给 PocketLLM 发送消息...',80 'disclaimer': 'AI 生成内容可能存在错误,请仔细核实',81 'think_tip': '自适应思考,多轮对话或Tool Call时可能不稳定',82 },83 'en': {84 'settings': 'Model Settings',85 'history_rounds': 'History Rounds',86 'max_length': 'Max Length',87 'temperature': 'Temperature',88 'thinking': '✨ Enable Deep Thinking',89 'tools': 'Tool Selection (max 4)',90 'language': 'Language',91 'send': 'Message PocketLLM...',92 'disclaimer': 'AI-generated content may be inaccurate, please verify',93 'think_tip': 'Adaptive thinking; may be unstable with multi-turn or Tool Call',94 }95}96 97def get_text(key):98 lang = st.session_state.get('lang', 'zh')99 return LANG_TEXTS.get(lang, {}).get(key, LANG_TEXTS['zh'].get(key, key))100 101# ================= 工具定义 & 执行 (完全保持原版) =================102TOOLS = [103 {"type": "function", "function": {"name": "calculate_math", "description": "计算数学表达式", "parameters": {"type": "object", "properties": {"expression": {"type": "string", "description": "数学表达式"}}, "required": ["expression"]}}},104 {"type": "function", "function": {"name": "get_current_time", "description": "获取当前时间", "parameters": {"type": "object", "properties": {"timezone": {"type": "string", "default": "Asia/Shanghai"}}, "required": []}}},105 {"type": "function", "function": {"name": "random_number", "description": "生成随机数", "parameters": {"type": "object", "properties": {"min": {"type": "integer"}, "max": {"type": "integer"}}, "required": ["min", "max"]}}},106 {"type": "function", "function": {"name": "text_length", "description": "计算文本长度", "parameters": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}},107 {"type": "function", "function": {"name": "unit_converter", "description": "单位转换", "parameters": {"type": "object", "properties": {"value": {"type": "number"}, "from_unit": {"type": "string"}, "to_unit": {"type": "string"}}, "required": ["value", "from_unit", "to_unit"]}}},108 {"type": "function", "function": {"name": "get_current_weather", "description": "获取天气", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}},109 {"type": "function", "function": {"name": "get_exchange_rate", "description": "获取汇率", "parameters": {"type": "object", "properties": {"from_currency": {"type": "string"}, "to_currency": {"type": "string"}}, "required": ["from_currency", "to_currency"]}}},110 {"type": "function", "function": {"name": "translate_text", "description": "翻译文本", "parameters": {"type": "object", "properties": {"text": {"type": "string"}, "target_lang": {"type": "string"}}, "required": ["text", "target_lang"]}}},111]112 113TOOL_SHORT_NAMES = {114 'calculate_math': '数学', 'get_current_time': '时间', 'random_number': '随机',115 'text_length': '字数', 'unit_converter': '单位', 'get_current_weather': '天气',116 'get_exchange_rate': '汇率', 'translate_text': '翻译'117}118 119def execute_tool(tool_name, args):120 import datetime121 try:122 if tool_name == 'calculate_math':123 return {"result": eval(args.get('expression', '0'))}124 elif tool_name == 'get_current_time':125 return {"result": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}126 elif tool_name == 'random_number':127 return {"result": random.randint(args.get('min', 0), args.get('max', 100))}128 elif tool_name == 'text_length':129 return {"result": len(args.get('text', ''))}130 elif tool_name == 'unit_converter':131 return {"result": f"{args.get('value', 0)} {args.get('from_unit', '')} = ? {args.get('to_unit', '')}"}132 elif tool_name == 'get_current_weather':133 return {"result": f"{args.get('city', 'Unknown')}: 晴, 7~10°C"}134 elif tool_name == 'get_exchange_rate':135 return {"result": f"1 {args.get('from_currency', 'USD')} = 7.2 {args.get('to_currency', 'CNY')}"}136 elif tool_name == 'translate_text':137 return {"result": f"[翻译结果]: hello world"}138 return {"result": "Unknown tool"}139 except Exception as e:140 return {"error": str(e)}141 142# ================= 原版 process_assistant_content (完全保持副本逻辑) =================143def process_assistant_content(content, is_streaming=False):144 # 处理tool_call标签,格式化显示145 if '<tool_call>' in content:146 def format_tool_call(match):147 try:148 tc = json.loads(match.group(1))149 name = tc.get('name', 'unknown')150 args = tc.get('arguments', {})151 return f'<div style="background: rgba(80, 110, 150, 0.20); border: 1px solid rgba(140, 170, 210, 0.30); padding: 10px 12px; border-radius: 12px; margin: 6px 0;"><div style="font-size:12px;opacity:.75;display:block;margin:0 0 6px 0;line-height:1;">ToolCalling</div><div><b>{name}</b>: {json.dumps(args, ensure_ascii=False)}</div></div>'152 except:153 return match.group(0)154 content = re.sub(r'<tool_call>(.*?)</tool_call>', format_tool_call, content, flags=re.DOTALL)155 156 # 流式生成且开启思考时,一开始就放到折叠里157 if is_streaming and st.session_state.get('enable_thinking', False) and '</think>' not in content and '<think>' not in content:158 m = re.search(r'(\n\n(?:我是|您好|你好)[^\n]*)', content)159 if m and m.start(1) > 5:160 i = m.start(1)161 think_part = content[:i]162 answer_part = content[i:]163 return f'<details open style="border-left: 2px solid #666; padding-left: 12px; margin: 8px 0;"><summary style="cursor: pointer; color: #888;">已思考</summary><div style="color: #aaa; font-size: 0.95em; margin-top: 8px; max-height: 100px; overflow-y: auto;">{think_part.strip()}</div></details>{answer_part}'164 elif len(content) > 5:165 return f'<details open style="border-left: 2px solid #666; padding-left: 12px; margin: 8px 0;"><summary style="cursor: pointer; color: #888;">思考中...</summary><div style="color: #aaa; font-size: 0.95em; margin-top: 8px; max-height: 100px; overflow-y: auto; display: flex; flex-direction: column-reverse;"><div style="margin-bottom: auto;">{content.strip().replace(chr(10), "<br>")}</div></div></details>'166 167 if '<think>' in content and '</think>' in content:168 def format_think(match):169 think_content = match.group(2)170 if think_content.replace('\n', '').strip():171 return f'<details open style="border-left: 2px solid #666; padding-left: 12px; margin: 8px 0;"><summary style="cursor: pointer; color: #888;">已思考</summary><div style="color: #aaa; font-size: 0.95em; margin-top: 8px; max-height: 100px; overflow-y: auto;">{think_content.strip()}</div></details>'172 return ''173 content = re.sub(r'(<think>)(.*?)(</think>)', format_think, content, flags=re.DOTALL)174 175 if '<think>' in content and '</think>' not in content:176 def format_think_in_progress(match):177 tc = match.group(1)178 return f'<details open style="border-left: 2px solid #666; padding-left: 12px; margin: 8px 0;"><summary style="cursor: pointer; color: #888;">思考中...</summary><div style="color: #aaa; font-size: 0.95em; margin-top: 8px; max-height: 100px; overflow-y: auto; display: flex; flex-direction: column-reverse;"><div style="margin-bottom: auto;">{tc.strip().replace(chr(10), "<br>")}</div></div></details>'179 content = re.sub(r'<think>(.*?)$', format_think_in_progress, content, flags=re.DOTALL)180 181 if '<think>' not in content and '</think>' in content:182 def format_think_no_start(match):183 think_content = match.group(1)184 if think_content.replace('\n', '').strip():185 return f'<details open style="border-left: 2px solid #666; padding-left: 12px; margin: 8px 0;"><summary style="cursor: pointer; color: #888;">已思考</summary><div style="color: #aaa; font-size: 0.95em; margin-top: 8px; max-height: 100px; overflow-y: auto;">{think_content.strip()}</div></details>'186 return ''187 content = re.sub(r'(.*?)</think>', format_think_no_start, content, flags=re.DOTALL)188 189 return content190 191# ================= 模型加载 =================192@st.cache_resource193def load_model_tokenizer(model_path):194 model = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True)195 tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)196 model = model.half().eval().to(device)197 return model, tokenizer198 199def clear_chat_messages():200 # 保存当前对话到历史(非空时)201 save_current_conversation()202 st.session_state.messages = []203 st.session_state.chat_messages = []204 205def save_current_conversation():206 """保存当前对话到历史记录列表中"""207 if "messages" not in st.session_state or not st.session_state.messages:208 return209 # 避免重复保存空对话210 if len(st.session_state.messages) == 0:211 return212 # 获取最后一条消息时间作为标题213 timestamp = datetime.now().strftime("%m-%d %H:%M")214 # 取用户的第一条消息作为预览215 preview = ""216 for msg in st.session_state.messages:217 if msg["role"] == "user":218 preview = msg["content"][:30]219 break220 title = f"{timestamp} - {preview}" if preview else timestamp221 222 # 保存到历史列表(最多20条)223 if "conversation_history" not in st.session_state:224 st.session_state.conversation_history = []225 # 避免与最后一条完全相同226 if st.session_state.conversation_history and st.session_state.conversation_history[-1]["messages"] == st.session_state.messages:227 return228 st.session_state.conversation_history.append({229 "title": title,230 "messages": st.session_state.messages.copy(),231 "chat_messages": st.session_state.chat_messages.copy()232 })233 # 保留最近20条234 if len(st.session_state.conversation_history) > 20:235 st.session_state.conversation_history = st.session_state.conversation_history[-20:]236 237def load_conversation(index):238 """加载指定索引的历史对话"""239 if 0 <= index < len(st.session_state.conversation_history):240 conv = st.session_state.conversation_history[index]241 st.session_state.messages = conv["messages"].copy()242 st.session_state.chat_messages = conv["chat_messages"].copy()243 st.rerun()244 245MODEL_PATHS = {246 "PocketLLM": ["/app/pout", "PocketLLM"]247}248 249# 动态扫描模型目录250# script_dir = os.path.dirname(os.path.abspath(__file__))251# MODEL_PATHS = {}252# for d in sorted(os.listdir(script_dir), reverse=True):253# full_path = os.path.join(script_dir, d)254# if os.path.isdir(full_path) and not d.startswith('.') and not d.startswith('_'):255# if any(f.endswith(('.bin', '.safetensors', '.pt')) or os.path.exists(os.path.join(full_path, 'model.safetensors.index.json')) for f in os.listdir(full_path) if os.path.isfile(os.path.join(full_path, f))):256# MODEL_PATHS[d] = [d, d]257# if not MODEL_PATHS:258# MODEL_PATHS = {"No models found": ["", "No models"]}259 260# ================= 侧边栏 UI (Logo 放大 + 深度思考独立开关) =================261with st.sidebar:262 # 侧边栏顶部:大 Logo + 标题263 st.markdown(f'<div style="text-align: center; margin-bottom: 20px;"><img src="{logo_b64}" style="width: 100px; border-radius: 12px;"></div>', unsafe_allow_html=True)264 st.markdown("<h2 style='text-align: center; margin-top: -10px; margin-bottom: 20px;'>PocketLLM</h2>", unsafe_allow_html=True)265 266 # 新建对话按钮267 if st.button("➕ 新建对话", use_container_width=True, type="primary"):268 clear_chat_messages()269 st.rerun()270 271 st.markdown("<div style='margin-top: 24px; font-size: 13px; color: #5f6368; font-weight: 500; margin-bottom: 8px;'>历史对话</div>", unsafe_allow_html=True)272 273 # 动态显示所有已保存的历史对话列表(不再显示独立的“之前的讨论...”按钮)274 if "conversation_history" in st.session_state and st.session_state.conversation_history:275 for idx, conv in enumerate(reversed(st.session_state.conversation_history)):276 # 倒序显示,最新的在上277 if st.button(f"📝 {conv['title']}", key=f"hist_{idx}", use_container_width=True):278 load_conversation(len(st.session_state.conversation_history) - 1 - idx)279 else:280 st.caption("暂无历史对话,新建对话后会自动保存")281 282 st.markdown("<hr style='margin: 20px 0;'>", unsafe_allow_html=True)283 284 # 深度思考开关作为独立显眼组件285 st.session_state.enable_thinking = st.toggle(get_text('thinking'), value=st.session_state.get('enable_thinking', False), help=get_text('think_tip'))286 287 st.markdown("<hr style='margin: 20px 0 12px 0;'>", unsafe_allow_html=True)288 289 # 设置收纳在 Popover290 with st.popover("⚙️ 设置", use_container_width=True):291 selected_model = st.selectbox('模型 (Model)', list(MODEL_PATHS.keys()), index=0)292 lang_options = {'中文': 'zh', 'English': 'en'}293 current_lang = st.session_state.get('lang', 'zh')294 lang_index = 0 if current_lang == 'zh' else 1295 lang_label = st.radio('语言 (Language)', list(lang_options.keys()), index=lang_index, horizontal=True)296 if lang_options[lang_label] != current_lang:297 st.session_state.lang = lang_options[lang_label]298 st.rerun()299 300 st.divider()301 st.session_state.history_chat_num = st.slider(get_text('history_rounds'), 0, 8, 0, step=2)302 st.session_state.max_new_tokens = st.slider(get_text('max_length'), 256, 8192, 8192, step=1)303 st.session_state.temperature = st.slider(get_text('temperature'), 0.6, 1.2, 0.90, step=0.01)304 305 st.divider()306 st.caption(get_text('tools'))307 st.session_state.selected_tools = []308 selected_count = sum(1 for tool in TOOLS if st.session_state.get(f"tool_{tool['function']['name']}", False))309 t_col1, t_col2 = st.columns(2)310 for i, tool in enumerate(TOOLS):311 name = tool['function']['name']312 short_name = TOOL_SHORT_NAMES.get(name, name)313 col = t_col1 if i % 2 == 0 else t_col2314 with col:315 checked = st.checkbox(short_name, key=f"tool_{name}", disabled=(selected_count >= 4 and not st.session_state.get(f"tool_{name}", False)))316 if checked and len(st.session_state.selected_tools) < 4:317 st.session_state.selected_tools.append(name)318 319model_path = MODEL_PATHS[selected_model][0]320slogan = f"我是 {MODEL_PATHS[selected_model][1]},有什么可以帮你的?" if st.session_state.get('lang', 'zh') == 'zh' else f"I am {MODEL_PATHS[selected_model][1]}, how can I help you?"321 322 323def render_user_msg(text):324 return f'<div style="display: flex; justify-content: flex-end; margin: 16px 0;"><div style="background-color: #f0f4f9; color: #1f1f1f; padding: 12px 20px; border-radius: 24px; font-size: 15px; max-width: 80%; line-height: 1.6;">{text}</div></div>'325 326def render_bot_msg(html_content):327 return f'<div style="display: flex; gap: 16px; margin: 20px 0;"><img src="{avatar_b64}" style="width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid #eee;"><div style="color: #1f1f1f; font-size: 15px; padding-top: 4px; line-height: 1.7; flex: 1; overflow-x: auto;">{html_content}</div></div>'328 329def setup_seed(seed):330 random.seed(seed)331 np.random.seed(seed)332 torch.manual_seed(seed)333 torch.cuda.manual_seed(seed)334 torch.cuda.manual_seed_all(seed)335 torch.backends.cudnn.deterministic = True336 torch.backends.cudnn.benchmark = False337 338# ================= Main =================339# def main():340 341 342# st.write("当前工作目录:", os.getcwd())343 344# st.write("/app 内容:", os.listdir("/app"))345 346# if os.path.exists("/app/src"):347# st.write("/app/src 内容:", os.listdir("/app/src"))348 349# if os.path.exists("/app/pout"):350# st.write("/app/pout 内容:", os.listdir("/app/pout"))351 352# model, tokenizer = load_model_tokenizer("../pout")353def main():354 # model, tokenizer = load_model_tokenizer("/app/pout")355 model, tokenizer = load_model_tokenizer(model_path)356 357 if "messages" not in st.session_state:358 st.session_state.messages = []359 st.session_state.chat_messages = []360 if "conversation_history" not in st.session_state:361 st.session_state.conversation_history = []362 363 messages = st.session_state.messages364 365 # 主界面顶部:横幅图片(修复截断问题:使用 contain 确保完整显示)366 if banner_b64 and not banner_b64.endswith("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"):367 st.markdown(368 f'<div style="display: flex; justify-content: center; overflow: visible; padding-top: 18px;">'369 f'<img src="{banner_b64}" style="width: auto; max-width: 500px; max-height: 150px; height: auto; object-fit: contain; border-radius: 12px; margin-bottom: 20px;">'370 f'</div>',371 unsafe_allow_html=True372 )373 374 # 欢迎界面 (无历史消息时)375 if not messages:376 st.markdown(377 f'<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; margin-top: 2rem;">'378 f'<h1 style="font-size: 28px; color: #202124; margin-bottom: 8px;">{slogan}</h1>'379 f'<p style="color: #5f6368; font-size: 14px;">{get_text("disclaimer")}</p>'380 '</div>',381 unsafe_allow_html=True382 )383 384 # 渲染历史消息 (带左侧头像)385 for message in messages:386 if message["role"] == "assistant":387 st.markdown(render_bot_msg(process_assistant_content(message["content"])), unsafe_allow_html=True)388 else:389 st.markdown(render_user_msg(message["content"]), unsafe_allow_html=True)390 391 392 prompt = st.chat_input(key="input", placeholder=get_text('send'))393 394 if prompt:395 396 st.markdown(render_user_msg(prompt), unsafe_allow_html=True)397 messages.append({"role": "user", "content": prompt[-st.session_state.max_new_tokens:]})398 st.session_state.chat_messages.append({"role": "user", "content": prompt[-st.session_state.max_new_tokens:]})399 400 placeholder = st.empty() # 用于流式输出401 402 random_seed = random.randint(0, 2 ** 32 - 1)403 setup_seed(random_seed)404 405 406 tools = [t for t in TOOLS if t['function']['name'] in st.session_state.get('selected_tools', [])] or None407 sys_prompt = [] if tools else [{"role": "system", "content": "你是PocketLLM,一个乐于助人、知识渊博的AI助手。请用完整且友好的方式回答用户问题。"}]408 st.session_state.chat_messages = sys_prompt + st.session_state.chat_messages[-(st.session_state.history_chat_num + 1):]409 410 template_kwargs = {"tokenize": False, "add_generation_prompt": True}411 if st.session_state.get('enable_thinking', False):412 template_kwargs["open_thinking"] = True413 if tools:414 template_kwargs["tools"] = tools415 416 new_prompt = tokenizer.apply_chat_template(st.session_state.chat_messages, **template_kwargs)417 inputs = tokenizer(new_prompt, return_tensors="pt", truncation=True).to(device)418 419 # 流式生成420 streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)421 generation_kwargs = {422 "input_ids": inputs.input_ids,423 "max_length": inputs.input_ids.shape[1] + st.session_state.max_new_tokens,424 "num_return_sequences": 1,425 "do_sample": True,426 "attention_mask": inputs.attention_mask,427 "pad_token_id": tokenizer.pad_token_id,428 "eos_token_id": tokenizer.eos_token_id,429 "temperature": st.session_state.temperature,430 "top_p": 0.85,431 "streamer": streamer,432 }433 434 Thread(target=model.generate, kwargs=generation_kwargs).start()435 436 answer = ""437 for new_text in streamer:438 answer += new_text439 placeholder.markdown(process_assistant_content(answer, is_streaming=True), unsafe_allow_html=True)440 441 full_answer = answer442 443 444 for _ in range(16):445 tool_calls = re.findall(r'<tool_call>(.*?)</tool_call>', answer, re.DOTALL)446 if not tool_calls:447 break448 st.session_state.chat_messages.append({"role": "assistant", "content": answer})449 tool_results = []450 for tc_str in tool_calls:451 try:452 tc = json.loads(tc_str.strip())453 result = execute_tool(tc.get('name', ''), tc.get('arguments', {}))454 st.session_state.chat_messages.append({"role": "tool", "content": json.dumps(result, ensure_ascii=False)})455 tool_results.append(f'<div style="background: rgba(90, 130, 110, 0.20); border: 1px solid rgba(150, 200, 170, 0.30); padding: 10px 12px; border-radius: 12px; margin: 6px 0;"><div style="font-size:12px;opacity:.75;display:block;margin:0 0 6px 0;line-height:1;">ToolCalled</div><div><b>{tc.get("name", "")}</b>: {json.dumps(result, ensure_ascii=False)}</div></div>')456 except:457 pass458 full_answer += "\n" + "\n".join(tool_results) + "\n"459 placeholder.markdown(process_assistant_content(full_answer, is_streaming=True), unsafe_allow_html=True)460 461 new_prompt = tokenizer.apply_chat_template(st.session_state.chat_messages, **template_kwargs)462 inputs = tokenizer(new_prompt, return_tensors="pt", truncation=True).to(device)463 streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)464 generation_kwargs["input_ids"] = inputs.input_ids465 generation_kwargs["attention_mask"] = inputs.attention_mask466 generation_kwargs["max_length"] = inputs.input_ids.shape[1] + st.session_state.max_new_tokens467 generation_kwargs["streamer"] = streamer468 469 Thread(target=model.generate, kwargs=generation_kwargs).start()470 answer = ""471 for new_text in streamer:472 answer += new_text473 placeholder.markdown(process_assistant_content(full_answer + answer, is_streaming=True), unsafe_allow_html=True)474 full_answer += answer475 476 answer = full_answer477 messages.append({"role": "assistant", "content": answer})478 st.session_state.chat_messages.append({"role": "assistant", "content": answer})479 480 481 482if __name__ == "__main__":483 main()