| |
| |
| """ |
| web-voice-teacher / scripts / app.py |
| |
| 原住民族語 AI 對話網頁版(升級自 LINE 教學機器人) |
| 特色: |
| - 捨棄 LINE Webhook,全面改用 Gradio 作為網頁介面 |
| - 支援麥克風直接錄音 |
| - 串接原民會 API (ASR / MT / TTS) 與 Gemini AI |
| - 透過 config/tribes.json 自動載入 16 族設定 |
| |
| 啟動方式: |
| python scripts/app.py |
| """ |
|
|
| import os |
| import json |
| import logging |
| import csv |
| import datetime |
| from docx import Document |
| from pathlib import Path |
|
|
| from dotenv import load_dotenv |
|
|
| |
| SKILL_ROOT = Path(__file__).parent.parent |
| ENV_FILE = SKILL_ROOT / ".env" |
| STATIC_DIR = SKILL_ROOT / "static" |
| STATIC_DIR.mkdir(exist_ok=True) |
|
|
| load_dotenv(ENV_FILE) |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") |
| log = logging.getLogger("web-voice-teacher") |
|
|
| |
| try: |
| import gradio as gr |
| import google.generativeai as genai |
| from pydub import AudioSegment |
| from gradio_client import Client, handle_file |
| except ImportError as e: |
| raise SystemExit( |
| f"缺少套件:{e}\n" |
| "請執行:pip install gradio google-generativeai gradio_client pydub python-dotenv" |
| ) |
|
|
| |
| GEMINI_KEY = os.getenv("GEMINI_KEY", "") or os.getenv("GOOGLE_API_KEY", "") |
| if not GEMINI_KEY: |
| log.warning("⚠️ GEMINI_KEY 未設定,AI 對話功能將無法運作!") |
|
|
| genai.configure(api_key=GEMINI_KEY) |
| |
| gemini_model = genai.GenerativeModel("gemini-3.6-flash") |
| chat_session = gemini_model.start_chat(history=[]) |
|
|
| |
| TRIBES_CFG_FILE = SKILL_ROOT / "config" / "tribes.json" |
|
|
| def load_tribe_config(): |
| if TRIBES_CFG_FILE.exists(): |
| with open(TRIBES_CFG_FILE, encoding="utf-8") as f: |
| raw = json.load(f) |
| return {name: {"asr": cfg["asr_id"], "mt": cfg["mt_name"]} |
| for name, cfg in raw["tribes"].items()} |
| return {} |
|
|
| TRIBE_CONFIG = load_tribe_config() |
| TRIBE_NAMES = list(TRIBE_CONFIG.keys()) |
|
|
| |
| |
| import ssl |
| _orig_create_default_context = ssl.create_default_context |
| def _unverified_create_default_context(*args, **kwargs): |
| ctx = _orig_create_default_context(*args, **kwargs) |
| ctx.check_hostname = False |
| ctx.verify_mode = ssl.CERT_NONE |
| return ctx |
| ssl.create_default_context = _unverified_create_default_context |
|
|
| asr_client = Client("https://ai-labs.ilrdf.org.tw/sapolita-kaldi/") |
| tts_client = Client("https://ai-labs.ilrdf.org.tw/hnang-kari-ai-asi-sluhay/") |
| mt_client = Client("https://ai-labs.ilrdf.org.tw/kari-seejiq-tnpusu-ai-hmjil/") |
|
|
| |
| |
| |
|
|
| def get_clean_value(res): |
| if isinstance(res, dict) and "value" in res: |
| return res["value"] |
| if isinstance(res, list) and len(res) > 0: |
| return res[0] |
| return res |
|
|
| def get_ai_response(user_text: str, tribe_name: str) -> str: |
| prompt = ( |
| f"你現在是與我對話的{tribe_name}族朋友。請用中文聊天。" |
| "規則:1.禁止教學。2.直接回答。3.限一短句。" |
| f"\n\n我說:{user_text}" |
| ) |
| return chat_session.send_message(prompt).text.strip() |
|
|
| |
| |
| def get_valid_speakers(tribe): |
| if not tribe: return [] |
| cfg = TRIBE_CONFIG[tribe] |
| try: |
| res = tts_client.predict(ethnicity=cfg["mt"], api_name="/lambda") |
| if isinstance(res, dict) and "choices" in res: |
| return [c[0] if isinstance(c, (list, tuple)) else c for c in res["choices"]] |
| except: |
| pass |
| return [] |
|
|
| |
| def process_voice(audio_path, tribe, speaker, history, session_phrases): |
| if not audio_path: |
| return history, None, session_phrases |
| |
| if not tribe: |
| return history + [["請先選擇族別!", ""]], None, session_phrases |
|
|
| cfg = TRIBE_CONFIG[tribe] |
| wav_path = str(STATIC_DIR / "temp_input.wav") |
| |
| try: |
| |
| AudioSegment.from_file(audio_path).export(wav_path, format="wav") |
|
|
| |
| native_in = asr_client.predict( |
| dialect_id=cfg["asr"], |
| audio_data=handle_file(wav_path), |
| api_name="/automatic_speech_recognition" |
| ) |
| |
| if not native_in or not native_in.strip(): |
| return history + [["🎙️ (無聲音輸入)", "🎙️ (聽取失敗,請再說一次)"]], None, session_phrases |
|
|
| |
| go_code = get_clean_value(mt_client.predict(ethnicity=cfg["mt"], api_name="/lambda")) |
| zh_in = mt_client.predict(text=native_in, src_lang=go_code, tgt_lang="zho_Hant", api_name="/translate") |
|
|
| |
| user_msg = f"[{tribe}]你:{native_in}\n💬 ({zh_in})" |
| history.append([user_msg, "⏳ 思考中..."]) |
| yield history, None, session_phrases |
|
|
| |
| ai_zh = get_ai_response(zh_in, tribe) |
|
|
| |
| bk_code = get_clean_value(mt_client.predict(ethnicity=cfg["mt"], api_name="/lambda_1")) |
| ai_nat = get_clean_value(mt_client.predict( |
| text=ai_zh, src_lang="zho_Hant", tgt_lang=bk_code, api_name="/translate_1" |
| )) |
|
|
| |
| session_phrases.append({"tribe": tribe, "user": native_in, "native": ai_nat, "zh": ai_zh}) |
|
|
| |
| ai_msg = f"🧑🏫 [{tribe}] {ai_nat}\n💬 ({ai_zh})" |
| history[-1][1] = ai_msg |
| yield history, None, session_phrases |
|
|
| |
| valid_speakers = get_valid_speakers(tribe) |
| if not speaker or speaker not in valid_speakers: |
| speaker = valid_speakers[0] if valid_speakers else get_clean_value(tts_client.predict(ethnicity=cfg["mt"], api_name="/lambda")) |
| |
| tts_out = tts_client.predict(ref=speaker, gen_text_input=ai_nat, api_name="/default_speaker_tts") |
| |
| |
| yield history, tts_out, session_phrases |
| |
| except Exception as e: |
| log.error(f"語音處理失敗:{e}") |
| history.append(["❌ 系統錯誤", str(e)]) |
| yield history, None, session_phrases |
|
|
| def render_phrases(session_phrases): |
| if not session_phrases: |
| return "目前還沒有紀錄喔!開始對話吧。" |
| return "\n\n".join([f"[{r['tribe']}]你:{r.get('user', '')}\n🧑🏫 [{r['tribe']}] {r['native']}\n💬 ({r['zh']})" for r in session_phrases[-10:]]) |
|
|
| def export_csv(session_phrases): |
| session_phrases = session_phrases or [] |
| now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M") |
| out_path = str(STATIC_DIR / f"{now_str}_notes.csv") |
| with open(out_path, 'w', encoding='utf-8-sig', newline='') as f: |
| writer = csv.writer(f) |
| writer.writerow(["族別", "你的提問", "族語回覆", "中文回覆"]) |
| for r in session_phrases: |
| writer.writerow([r['tribe'], r.get('user', ''), r['native'], r['zh']]) |
| return out_path |
|
|
| def export_doc(session_phrases): |
| session_phrases = session_phrases or [] |
| doc = Document() |
| doc.add_heading('原住民族語學習筆記', 0) |
| for r in session_phrases: |
| p = doc.add_paragraph() |
| p.add_run(f"[{r['tribe']}]你:{r.get('user', '')}\n") |
| p.add_run(f"[{r['tribe']}] {r['native']}\n").bold = True |
| p.add_run(f"({r['zh']})") |
| |
| now_str = datetime.datetime.now().strftime("%Y%m%d_%H%M") |
| out_path = str(STATIC_DIR / f"{now_str}_notes.docx") |
| doc.save(out_path) |
| return out_path |
|
|
| |
| |
| |
| custom_css = """ |
| body { |
| font-family: 'Noto Sans TC', sans-serif; |
| background: linear-gradient(135deg, #e0f7fa 0%, #e1f5fe 100%); |
| } |
| .gradio-container { |
| font-size: 18px !important; |
| background: rgba(255, 255, 255, 0.75) !important; |
| backdrop-filter: blur(15px); |
| border-radius: 20px !important; |
| box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.08) !important; |
| padding: 20px !important; |
| border: 2px solid #b2ebf2 !important; |
| } |
| .message.user { |
| background-color: #e3f2fd !important; |
| border: 1px solid #bbdefb !important; |
| border-radius: 15px !important; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.05) !important; |
| } |
| .message.bot { |
| background-color: #ffffff !important; |
| border: 1px solid #eceff1 !important; |
| border-radius: 15px !important; |
| box-shadow: 0 2px 10px rgba(0,0,0,0.05) !important; |
| } |
| .message p { font-size: 20px !important; line-height: 1.5; font-family: 'Times New Roman', 'Noto Sans TC', sans-serif !important; } |
| textarea { font-family: 'Times New Roman', 'Noto Sans TC', sans-serif !important; background: rgba(255,255,255,0.9) !important; border: 1px solid #b0bec5 !important; } |
| span[data-testid="block-info"] { font-size: 18px !important; } |
| .prose p { font-size: 20px !important; } |
| .prose h1 { font-size: 32px !important; color: #006064 !important; } |
| .prose h3 { font-size: 24px !important; color: #00838f !important; } |
| .form-label, .block-label { font-size: 20px !important; font-weight: bold !important; color: #004d40 !important; } |
| #font-size-radio { display: flex; flex-direction: row; align-items: center; gap: 15px; } |
| #font-size-radio span[data-testid="block-info"] { margin-bottom: 0 !important; white-space: nowrap; } |
| |
| button { transition: all 0.3s ease !important; } |
| button:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 15px rgba(0, 131, 143, 0.2) !important; |
| } |
| |
| /* 增強各區塊邊界與聊天視窗底色 */ |
| .block { |
| border: 1px solid #b0bec5 !important; |
| box-shadow: 0 1px 4px rgba(0,0,0,0.05) !important; |
| } |
| #chat-window { |
| background-color: #f1f8e9 !important; /* 淺淡綠色 */ |
| border: 2px solid #aed581 !important; |
| } |
| #chat-window > div { |
| background-color: transparent !important; |
| } |
| #header-row .block { |
| border: none !important; |
| box-shadow: none !important; |
| background: transparent !important; |
| } |
| """ |
|
|
| js_head = """<script> |
| window.addEventListener('beforeunload', function (e) { e.preventDefault(); e.returnValue = ''; }); |
| document.addEventListener("DOMContentLoaded", function() { |
| new MutationObserver(function() { |
| document.querySelectorAll("button").forEach(b => { |
| b.childNodes.forEach(n => { |
| if (n.nodeType === 3) { |
| if (n.textContent.trim() === "Record") n.textContent = " 錄音"; |
| if (n.textContent.trim() === "Stop") n.textContent = " 停止"; |
| } |
| }); |
| }); |
| }).observe(document.body, {childList: true, subtree: true}); |
| }); |
| </script>""" |
|
|
| with gr.Blocks(title="原住民族語 AI 對話", css=custom_css, theme=gr.themes.Soft(primary_hue="cyan", neutral_hue="slate"), head=js_head) as app: |
| |
| session_phrases = gr.State([]) |
| |
| with gr.Row(elem_id="header-row"): |
| with gr.Column(scale=3): |
| gr.Markdown("# 🗣️ 原住民族語 AI 對話機器人\n選擇你的族別,按下麥克風開始對話!") |
| with gr.Column(scale=2): |
| font_size_radio = gr.Radio(choices=["正常", "中 (150%)", "大 (200%)"], value="正常", label="網頁文字大小", interactive=True, elem_id="font-size-radio") |
| font_size_radio.change(None, [font_size_radio], js="(v) => { document.body.style.zoom = v.includes('150') ? '150%' : v.includes('200') ? '200%' : '100%'; }") |
|
|
| with gr.Row(): |
| with gr.Column(scale=4): |
| with gr.Row(): |
| tribe_dropdown = gr.Dropdown(choices=TRIBE_NAMES, label="🌍 選擇族別", value="阿美" if "阿美" in TRIBE_NAMES else None) |
| speaker_dropdown = gr.Dropdown(label="🔊 選擇語音", choices=[], interactive=True) |
| chatbot = gr.Chatbot(label="對話視窗", height=400, elem_id="chat-window") |
| |
| with gr.Row(): |
| audio_input = gr.Audio(sources=["microphone"], type="filepath", label="🎙️ 按下錄音") |
| |
| audio_output = gr.Audio(label="🔊 AI 語音回應", autoplay=True) |
|
|
| with gr.Column(scale=1): |
| gr.Markdown("### 📚 學習筆記 (最新 10 筆)") |
| history_box = gr.Textbox(label="你的專屬語料庫", lines=15, interactive=False, value="目前還沒有紀錄喔!開始對話吧。") |
| with gr.Row(): |
| dl_csv_btn = gr.DownloadButton("📥 下載 CSV") |
| dl_doc_btn = gr.DownloadButton("📥 下載 Word") |
|
|
| dl_csv_btn.click(fn=export_csv, inputs=[session_phrases], outputs=[dl_csv_btn]) |
| dl_doc_btn.click(fn=export_doc, inputs=[session_phrases], outputs=[dl_doc_btn]) |
| |
| def update_speakers(tribe): |
| choices = get_valid_speakers(tribe) |
| val = choices[0] if choices else None |
| return gr.update(choices=choices, value=val) |
| |
| tribe_dropdown.change(fn=update_speakers, inputs=[tribe_dropdown], outputs=[speaker_dropdown]) |
| app.load(fn=update_speakers, inputs=[tribe_dropdown], outputs=[speaker_dropdown]) |
|
|
| |
| audio_input.stop_recording( |
| fn=process_voice, |
| inputs=[audio_input, tribe_dropdown, speaker_dropdown, chatbot, session_phrases], |
| outputs=[chatbot, audio_output, session_phrases] |
| ).then( |
| fn=render_phrases, |
| inputs=[session_phrases], |
| outputs=[history_box] |
| ).then( |
| |
| fn=lambda: None, |
| outputs=[audio_input] |
| ) |
|
|
| gr.HTML(""" |
| <div style="text-align: center; margin-top: 50px; padding-top: 20px; border-top: 1px solid #ccc; color: #666; font-size: 14px;"> |
| <p>開發者:<b>LowkingNowbucyang</b> | 使用模型出自:<b><a href="https://ai-labs.ilrdf.org.tw/" target="_blank" style="color: #0066cc; text-decoration: none;">ILRDF (原語會)</a></b></p> |
| </div> |
| """) |
|
|
| if __name__ == "__main__": |
| port = int(os.getenv("PORT", 7860)) |
| log.info(f"🚀 啟動網頁版 AI 對話 (Port: {port})") |
| app.launch(server_name="0.0.0.0", server_port=port, share=False) |
|
|