Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| # 範例客服知識庫:部署到 Hugging Face Spaces 時不需要額外資料庫或 API 金鑰。 | |
| # 若未來要串接真實模型、RAG 或 Supabase,可替換 search_answer() 的查詢邏輯。 | |
| FAQ_DATA = [ | |
| { | |
| "keywords": ["營業", "時間", "幾點", "開門"], | |
| "answer": "我們的線上客服系統全天候開放。若是人工客服,服務時間為週一至週五 09:00-18:00。", | |
| }, | |
| { | |
| "keywords": ["退貨", "退款", "取消", "換貨"], | |
| "answer": "若需要退貨或退款,請提供訂單編號與問題描述,客服人員會協助確認後續流程。", | |
| }, | |
| { | |
| "keywords": ["訂單", "查詢", "物流", "配送", "出貨"], | |
| "answer": "您可以提供訂單編號,我們會協助查詢目前的出貨與配送狀態。", | |
| }, | |
| { | |
| "keywords": ["付款", "信用卡", "發票", "刷卡"], | |
| "answer": "目前支援信用卡、轉帳與行動支付。發票資訊可於訂單成立後在會員中心查詢。", | |
| }, | |
| { | |
| "keywords": ["帳號", "登入", "密碼", "註冊"], | |
| "answer": "若無法登入,請先使用忘記密碼功能重設密碼;若仍有問題,請提供註冊信箱讓客服協助。", | |
| }, | |
| ] | |
| INITIAL_CHAT_HISTORY = [ | |
| (None, "您好,歡迎使用 AI 線上客服系統。請輸入您想查詢的問題,我會協助您快速找到合適的回覆。") | |
| ] | |
| SERVICE_PROMPTS = { | |
| "預約與營業時間": { | |
| "message": "您選擇了「預約與營業時間」。可以輸入想預約的日期、時段或想確認的營業時間。", | |
| "placeholder": "例如:我想預約週五下午,請問有空檔嗎?", | |
| }, | |
| "訂單與服務進度": { | |
| "message": "您選擇了「訂單與服務進度」。請提供訂單編號、服務項目或想查詢的進度內容。", | |
| "placeholder": "例如:我的訂單編號是 A12345,想查詢目前進度", | |
| }, | |
| "付款與發票資訊": { | |
| "message": "您選擇了「付款與發票資訊」。可以詢問付款方式、刷卡、轉帳或發票相關問題。", | |
| "placeholder": "例如:請問可以刷卡嗎?發票要在哪裡查詢?", | |
| }, | |
| "會員帳號協助": { | |
| "message": "您選擇了「會員帳號協助」。可以描述登入、註冊、密碼或會員資料相關問題。", | |
| "placeholder": "例如:我忘記密碼,無法登入會員中心", | |
| }, | |
| "退貨與退款流程": { | |
| "message": "您選擇了「退貨與退款流程」。請提供訂單編號與需要協助的退款或退貨狀況。", | |
| "placeholder": "例如:我想申請退款,訂單編號是 A12345", | |
| }, | |
| } | |
| def search_answer(question: str) -> str: | |
| """依照使用者輸入的關鍵字回傳客服示範答案。""" | |
| clean_question = question.strip() | |
| if not clean_question: | |
| return "請先輸入想查詢的問題,我會協助您找出可能的答案。" | |
| # 使用簡單關鍵字比對做教學示範,避免 Space 部署時需要下載大型模型。 | |
| for item in FAQ_DATA: | |
| if any(keyword in clean_question for keyword in item["keywords"]): | |
| return item["answer"] | |
| return ( | |
| "目前沒有找到完全符合的答案。您可以換個方式描述問題," | |
| "例如提供訂單編號、付款方式或遇到的錯誤訊息。" | |
| ) | |
| def submit_message( | |
| message: str, | |
| history: list[tuple[str | None, str]], | |
| service_topic: str, | |
| ) -> tuple[list[tuple[str | None, str]], str]: | |
| """將使用者訊息、選單情境與系統回覆加入聊天紀錄,並清空輸入框。""" | |
| history = list(history or INITIAL_CHAT_HISTORY) | |
| display_message = message.strip() or f"我想詢問:{service_topic}" | |
| query = f"{service_topic} {display_message}" | |
| answer = search_answer(query) | |
| history.append((display_message, answer)) | |
| return history, "" | |
| def select_service_topic( | |
| service_topic: str, | |
| history: list[tuple[str | None, str]], | |
| ) -> tuple[list[tuple[str | None, str]], str]: | |
| """選擇左側服務項目時,立即在聊天框顯示對應提示並更新輸入提示文字。""" | |
| history = list(history or INITIAL_CHAT_HISTORY) | |
| guide = SERVICE_PROMPTS.get(service_topic, SERVICE_PROMPTS["預約與營業時間"]) | |
| assistant_message = guide["message"] | |
| if not history or history[-1][1] != assistant_message: | |
| history.append((None, assistant_message)) | |
| return history, guide["placeholder"] | |
| # 自訂 CSS 用於建立高端醫美接待台風格的客服介面。 | |
| CUSTOM_CSS = """ | |
| :root { | |
| --ink: #17212b; | |
| --muted: #687583; | |
| --line: #e3e8ec; | |
| --panel: #ffffff; | |
| --soft: #f7faf9; | |
| --sage: #3f766c; | |
| --sage-dark: #244f49; | |
| --rose: #c99a8a; | |
| --rose-soft: #f6e9e4; | |
| } | |
| .gradio-container { | |
| max-width: 1180px !important; | |
| margin: 0 auto !important; | |
| background: | |
| radial-gradient(circle at top left, rgba(201, 154, 138, 0.18), transparent 34%), | |
| linear-gradient(180deg, #fbfcfb 0%, #f3f7f6 100%) !important; | |
| color: var(--ink); | |
| } | |
| body { | |
| background: #f3f7f6; | |
| } | |
| .main-shell { | |
| padding: 26px 18px 20px; | |
| } | |
| #hero { | |
| background: | |
| linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 249, 0.92)), | |
| linear-gradient(135deg, rgba(63, 118, 108, 0.14), rgba(201, 154, 138, 0.18)); | |
| border: 1px solid rgba(63, 118, 108, 0.16); | |
| border-radius: 18px; | |
| padding: 30px 34px; | |
| color: var(--ink); | |
| box-shadow: 0 22px 60px rgba(36, 79, 73, 0.12); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| #hero::after { | |
| content: ""; | |
| position: absolute; | |
| width: 210px; | |
| height: 210px; | |
| right: -76px; | |
| top: -92px; | |
| border-radius: 999px; | |
| border: 34px solid rgba(201, 154, 138, 0.16); | |
| } | |
| .eyebrow { | |
| color: var(--sage); | |
| font-size: 13px; | |
| font-weight: 700; | |
| letter-spacing: 1.8px; | |
| text-transform: uppercase; | |
| margin-bottom: 10px; | |
| } | |
| #hero h1 { | |
| font-size: 38px; | |
| line-height: 1.2; | |
| margin: 0 0 10px; | |
| letter-spacing: 0; | |
| color: var(--ink); | |
| } | |
| #hero p { | |
| color: #4f5f68; | |
| font-size: 16px; | |
| line-height: 1.7; | |
| margin: 0; | |
| max-width: 720px; | |
| } | |
| .status-grid { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr); | |
| gap: 14px; | |
| margin: 16px 0; | |
| } | |
| .status-item { | |
| background: rgba(255, 255, 255, 0.86); | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| padding: 16px 18px; | |
| box-shadow: 0 12px 30px rgba(23, 33, 43, 0.04); | |
| } | |
| .status-label { | |
| color: var(--muted); | |
| font-size: 13px; | |
| margin-bottom: 4px; | |
| } | |
| .status-value { | |
| color: var(--sage-dark); | |
| font-size: 16px; | |
| font-weight: 700; | |
| } | |
| .clinic-layout { | |
| display: grid; | |
| grid-template-columns: 300px minmax(0, 1fr); | |
| gap: 18px; | |
| align-items: stretch; | |
| } | |
| .concierge-card { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| padding: 22px; | |
| box-shadow: 0 18px 42px rgba(23, 33, 43, 0.08); | |
| } | |
| .concierge-card h2 { | |
| font-size: 19px; | |
| margin: 0 0 8px; | |
| color: var(--ink); | |
| } | |
| .concierge-card p { | |
| color: var(--muted); | |
| line-height: 1.7; | |
| margin: 0 0 16px; | |
| font-size: 14px; | |
| } | |
| .menu-panel .wrap { | |
| gap: 10px; | |
| } | |
| .menu-panel label { | |
| border: 1px solid #e5ddd9 !important; | |
| background: #fffaf8 !important; | |
| border-radius: 12px !important; | |
| padding: 12px 13px !important; | |
| margin-bottom: 8px !important; | |
| color: #614d47 !important; | |
| font-weight: 650 !important; | |
| } | |
| .menu-panel label:hover { | |
| border-color: var(--rose) !important; | |
| background: #fff5f1 !important; | |
| } | |
| .mini-note { | |
| border-top: 1px solid var(--line); | |
| padding-top: 15px; | |
| color: var(--muted); | |
| font-size: 13px; | |
| line-height: 1.65; | |
| } | |
| #workspace { | |
| background: rgba(255, 255, 255, 0.94); | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| padding: 18px; | |
| box-shadow: 0 18px 42px rgba(23, 33, 43, 0.08); | |
| } | |
| #chatbot { | |
| border: 1px solid #dbe4e2; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| background: linear-gradient(180deg, #ffffff 0%, #f8fbfa 100%); | |
| min-height: 430px; | |
| } | |
| #input-row { | |
| align-items: stretch; | |
| margin-top: 16px; | |
| } | |
| #send-button { | |
| min-height: 72px; | |
| border-radius: 14px !important; | |
| background: linear-gradient(135deg, var(--sage-dark), var(--sage)) !important; | |
| border: 1px solid rgba(36, 79, 73, 0.25) !important; | |
| box-shadow: 0 14px 26px rgba(63, 118, 108, 0.22); | |
| font-weight: 700 !important; | |
| } | |
| #send-button:hover { | |
| filter: brightness(1.04); | |
| } | |
| textarea, input { | |
| border-radius: 14px !important; | |
| } | |
| #notice { | |
| background: rgba(255, 255, 255, 0.78); | |
| border: 1px solid var(--line); | |
| border-left: 4px solid var(--rose); | |
| border-radius: 16px; | |
| color: var(--muted); | |
| padding: 14px 18px; | |
| margin-top: 16px; | |
| } | |
| #notice h3 { | |
| color: var(--ink); | |
| margin-top: 0; | |
| margin-bottom: 8px; | |
| font-size: 16px; | |
| } | |
| @media (max-width: 760px) { | |
| .main-shell { | |
| padding: 14px 8px; | |
| } | |
| #hero { | |
| padding: 22px 18px; | |
| } | |
| #hero h1 { | |
| font-size: 28px; | |
| } | |
| .status-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .clinic-layout { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| """ | |
| with gr.Blocks(title="AI 線上客服系統", css=CUSTOM_CSS, theme=gr.themes.Soft()) as demo: | |
| with gr.Column(elem_classes=["main-shell"]): | |
| gr.HTML( | |
| """ | |
| <section id="hero"> | |
| <div class="eyebrow">Concierge Support Desk</div> | |
| <h1>AI 線上客服系統</h1> | |
| <p>以高端接待流程為設計核心,提供清楚、穩定且有質感的線上客服體驗。輸入問題後,系統會依照示範客服知識庫回傳可能的答案。</p> | |
| </section> | |
| <section class="status-grid"> | |
| <div class="status-item"> | |
| <div class="status-label">服務狀態</div> | |
| <div class="status-value">線上服務中</div> | |
| </div> | |
| </section> | |
| """ | |
| ) | |
| with gr.Row(elem_classes=["clinic-layout"]): | |
| with gr.Column(elem_classes=["concierge-card", "menu-panel"]): | |
| gr.HTML( | |
| """ | |
| <div class="eyebrow">Service Menu</div> | |
| <h2>專屬客服接待</h2> | |
| <p>請先選擇想諮詢的服務類別,再輸入問題。系統會依照選單情境協助回覆。</p> | |
| """ | |
| ) | |
| service_topic = gr.Radio( | |
| choices=[ | |
| "預約與營業時間", | |
| "訂單與服務進度", | |
| "付款與發票資訊", | |
| "會員帳號協助", | |
| "退貨與退款流程", | |
| ], | |
| value="預約與營業時間", | |
| label="服務選單", | |
| elem_classes=["menu-radio"], | |
| ) | |
| gr.HTML( | |
| """ | |
| <div class="mini-note">建議輸入具體資訊,例如訂單編號、付款方式、遇到的狀況或想確認的服務項目。</div> | |
| """ | |
| ) | |
| with gr.Column(elem_id="workspace"): | |
| # Chatbot 使用 bubble layout,呈現仿聊天對話氣泡效果。 | |
| chatbot = gr.Chatbot( | |
| value=INITIAL_CHAT_HISTORY, | |
| label="AI 客服對話", | |
| elem_id="chatbot", | |
| height=500, | |
| layout="bubble", | |
| ) | |
| # State 儲存目前聊天紀錄,讓每次送出後能延續上下文畫面。 | |
| chat_state = gr.State(INITIAL_CHAT_HISTORY) | |
| with gr.Row(elem_id="input-row"): | |
| question_input = gr.Textbox( | |
| label="請輸入問題", | |
| placeholder=SERVICE_PROMPTS["預約與營業時間"]["placeholder"], | |
| lines=2, | |
| scale=5, | |
| ) | |
| submit_btn = gr.Button( | |
| "送出查詢", | |
| variant="primary", | |
| scale=1, | |
| elem_id="send-button", | |
| ) | |
| service_topic.change( | |
| fn=select_service_topic, | |
| inputs=[service_topic, chat_state], | |
| outputs=[chatbot, question_input], | |
| api_name=False, | |
| ).then( | |
| fn=lambda history: history, | |
| inputs=chatbot, | |
| outputs=chat_state, | |
| api_name=False, | |
| ) | |
| submit_btn.click( | |
| fn=submit_message, | |
| inputs=[question_input, chat_state, service_topic], | |
| outputs=[chatbot, question_input], | |
| api_name=False, | |
| ).then( | |
| fn=lambda history: history, | |
| inputs=chatbot, | |
| outputs=chat_state, | |
| api_name=False, | |
| ) | |
| # 讓使用者按 Enter 也能送出,符合聊天介面的常見操作。 | |
| question_input.submit( | |
| fn=submit_message, | |
| inputs=[question_input, chat_state, service_topic], | |
| outputs=[chatbot, question_input], | |
| api_name=False, | |
| ).then( | |
| fn=lambda history: history, | |
| inputs=chatbot, | |
| outputs=chat_state, | |
| api_name=False, | |
| ) | |
| gr.Markdown( | |
| "### 使用提醒\n此模型僅供教學展示,回覆內容不代表正式客服承諾;實際服務請以官方公告與人工客服回覆為準。", | |
| elem_id="notice", | |
| ) | |
| if __name__ == "__main__": | |
| # Hugging Face Spaces 會自動執行 app.py;本機測試時可用 python app.py 啟動。 | |
| demo.launch() | |