Spaces:
Running
Running
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>單牌占卜法 - One Card Tarot</title> | |
| <script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| padding: 20px; | |
| } | |
| .container { | |
| background: rgba(255, 255, 255, 0.95); | |
| border-radius: 20px; | |
| padding: 40px; | |
| max-width: 800px; | |
| width: 100%; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | |
| } | |
| h1 { | |
| text-align: center; | |
| color: #2a5298; | |
| margin-bottom: 10px; | |
| font-size: 2.5em; | |
| } | |
| .subtitle { | |
| text-align: center; | |
| color: #666; | |
| margin-bottom: 30px; | |
| } | |
| .input-section { | |
| margin-bottom: 30px; | |
| } | |
| label { | |
| display: block; | |
| margin-bottom: 10px; | |
| color: #333; | |
| font-weight: 600; | |
| } | |
| textarea { | |
| width: 100%; | |
| padding: 15px; | |
| border: 2px solid #ddd; | |
| border-radius: 10px; | |
| font-size: 16px; | |
| resize: vertical; | |
| min-height: 100px; | |
| transition: border-color 0.3s; | |
| } | |
| textarea:focus { | |
| outline: none; | |
| border-color: #2a5298; | |
| } | |
| .btn { | |
| width: 100%; | |
| padding: 15px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| border-radius: 10px; | |
| font-size: 18px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4); | |
| } | |
| .btn:active { | |
| transform: translateY(0); | |
| } | |
| .btn:disabled { | |
| background: #ccc; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .result-section { | |
| margin-top: 30px; | |
| display: none; | |
| } | |
| .result-section.show { | |
| display: block; | |
| animation: fadeIn 0.5s; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .card-display { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| margin-bottom: 30px; | |
| } | |
| .card-container { | |
| position: relative; | |
| width: 300px; | |
| height: 500px; | |
| margin-bottom: 20px; | |
| } | |
| .card { | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 15px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); | |
| overflow: hidden; | |
| transition: transform 0.6s; | |
| transform-style: preserve-3d; | |
| background: #000; | |
| } | |
| .card.reversed { | |
| transform: rotate(180deg); | |
| } | |
| .card img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| } | |
| .card-info { | |
| text-align: center; | |
| padding: 20px; | |
| background: #f8f9fa; | |
| border-radius: 10px; | |
| width: 100%; | |
| } | |
| .card-name { | |
| font-size: 24px; | |
| color: #2a5298; | |
| font-weight: bold; | |
| margin-bottom: 10px; | |
| } | |
| .card-position { | |
| font-size: 18px; | |
| color: #666; | |
| font-style: italic; | |
| } | |
| .interpretation { | |
| background: #f8f9fa; | |
| padding: 25px; | |
| border-radius: 10px; | |
| border-left: 5px solid #667eea; | |
| } | |
| .interpretation h3 { | |
| color: #2a5298; | |
| margin-bottom: 15px; | |
| font-size: 20px; | |
| } | |
| /* Markdown 渲染樣式 */ | |
| .markdown-body { | |
| line-height: 1.8; | |
| color: #333; | |
| word-wrap: break-word; | |
| } | |
| .markdown-body h1, | |
| .markdown-body h2, | |
| .markdown-body h3, | |
| .markdown-body h4, | |
| .markdown-body h5, | |
| .markdown-body h6 { | |
| margin-top: 20px; | |
| margin-bottom: 12px; | |
| font-weight: 600; | |
| line-height: 1.4; | |
| color: #2a5298; | |
| } | |
| .markdown-body h1 { font-size: 1.8em; border-bottom: 2px solid #eee; padding-bottom: 8px; } | |
| .markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 6px; } | |
| .markdown-body h3 { font-size: 1.3em; } | |
| .markdown-body h4 { font-size: 1.15em; } | |
| .markdown-body p { | |
| margin-bottom: 12px; | |
| } | |
| .markdown-body ul, | |
| .markdown-body ol { | |
| margin-bottom: 12px; | |
| padding-left: 2em; | |
| } | |
| .markdown-body li { | |
| margin-bottom: 6px; | |
| } | |
| .markdown-body strong { | |
| color: #2a5298; | |
| font-weight: 700; | |
| } | |
| .markdown-body em { | |
| color: #555; | |
| font-style: italic; | |
| } | |
| .markdown-body code { | |
| background: #f0f0f0; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-family: 'Courier New', Courier, monospace; | |
| font-size: 0.9em; | |
| color: #c7254e; | |
| } | |
| .markdown-body pre { | |
| background: #282c34; | |
| color: #abb2bf; | |
| padding: 15px; | |
| border-radius: 8px; | |
| overflow-x: auto; | |
| margin-bottom: 15px; | |
| } | |
| .markdown-body pre code { | |
| background: transparent; | |
| color: inherit; | |
| padding: 0; | |
| font-size: 0.9em; | |
| } | |
| .markdown-body blockquote { | |
| border-left: 4px solid #667eea; | |
| padding: 10px 15px; | |
| margin: 15px 0; | |
| background: #f9f9ff; | |
| color: #555; | |
| border-radius: 0 8px 8px 0; | |
| } | |
| .markdown-body a { | |
| color: #667eea; | |
| text-decoration: none; | |
| border-bottom: 1px dashed #667eea; | |
| } | |
| .markdown-body a:hover { | |
| color: #764ba2; | |
| border-bottom-style: solid; | |
| } | |
| .markdown-body hr { | |
| border: none; | |
| border-top: 1px solid #ddd; | |
| margin: 20px 0; | |
| } | |
| .markdown-body table { | |
| border-collapse: collapse; | |
| width: 100%; | |
| margin-bottom: 15px; | |
| } | |
| .markdown-body table th, | |
| .markdown-body table td { | |
| border: 1px solid #ddd; | |
| padding: 8px 12px; | |
| text-align: left; | |
| } | |
| .markdown-body table th { | |
| background: #f0f0f0; | |
| font-weight: 600; | |
| } | |
| .markdown-body table tr:nth-child(even) { | |
| background: #f9f9f9; | |
| } | |
| .section-divider { | |
| border: none; | |
| border-top: 1px dashed #ccc; | |
| margin: 20px 0; | |
| } | |
| .reasoning-section { | |
| background: #fff9e6; | |
| padding: 15px; | |
| border-radius: 8px; | |
| border-left: 4px solid #ffc107; | |
| margin-bottom: 20px; | |
| } | |
| .reasoning-section summary { | |
| cursor: pointer; | |
| font-weight: 600; | |
| color: #856404; | |
| user-select: none; | |
| } | |
| .reasoning-section[open] summary { | |
| margin-bottom: 10px; | |
| } | |
| .loading { | |
| text-align: center; | |
| padding: 40px; | |
| display: none; | |
| } | |
| .loading.show { | |
| display: block; | |
| } | |
| .spinner { | |
| border: 4px solid #f3f3f3; | |
| border-top: 4px solid #667eea; | |
| border-radius: 50%; | |
| width: 50px; | |
| height: 50px; | |
| animation: spin 1s linear infinite; | |
| margin: 0 auto 20px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .error { | |
| background: #fee; | |
| color: #c33; | |
| padding: 15px; | |
| border-radius: 10px; | |
| margin-top: 20px; | |
| display: none; | |
| } | |
| .error.show { | |
| display: block; | |
| } | |
| .status { | |
| text-align: center; | |
| padding: 10px; | |
| margin-bottom: 20px; | |
| border-radius: 5px; | |
| display: none; | |
| } | |
| .status.show { | |
| display: block; | |
| } | |
| .status.connecting { | |
| background: #fff3cd; | |
| color: #856404; | |
| } | |
| .status.connected { | |
| background: #d4edda; | |
| color: #155724; | |
| } | |
| .status.error { | |
| background: #f8d7da; | |
| color: #721c24; | |
| } | |
| .img-debug { | |
| font-size: 12px; | |
| color: #999; | |
| margin-top: 5px; | |
| word-break: break-all; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>🔮 單牌占卜法</h1> | |
| <p class="subtitle">One Card Tarot Spread</p> | |
| <div id="status" class="status">準備連接...</div> | |
| <div class="input-section"> | |
| <label for="question">請輸入您的問題:</label> | |
| <textarea id="question" placeholder="例如:我最近的工作運勢如何?我應該如何面對這段感情?"></textarea> | |
| </div> | |
| <button class="btn" id="drawBtn" onclick="drawCard()">抽取塔羅牌</button> | |
| <div class="loading" id="loading"> | |
| <div class="spinner"></div> | |
| <p>正在抽取塔羅牌並進行解讀...</p> | |
| </div> | |
| <div class="error" id="error"></div> | |
| <div class="result-section" id="result"> | |
| <div class="card-display"> | |
| <div class="card-container"> | |
| <div class="card" id="cardImage"> | |
| <img src="" alt="Tarot Card" id="cardImg"> | |
| </div> | |
| </div> | |
| <div class="card-info"> | |
| <div class="card-name" id="cardName"></div> | |
| <div class="card-position" id="cardPosition"></div> | |
| <div class="img-debug" id="imgDebug"></div> | |
| </div> | |
| </div> | |
| <div class="interpretation"> | |
| <h3>📖 牌義解讀</h3> | |
| <div id="reasoningContainer"></div> | |
| <div class="markdown-body" id="interpretation"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // 配置 marked.js | |
| marked.setOptions({ | |
| breaks: true, // 支持換行符轉換為 <br> | |
| gfm: true, // 啟用 GitHub Flavored Markdown | |
| headerIds: true, // 為標題添加 id | |
| mangle: false // 不轉義郵箱 | |
| }); | |
| // 配置 | |
| const UNIQUE_PREFIX = 'my_secure_hy3_test_8f7a'; | |
| const MQTT_BROKER = 'wss://broker.emqx.io:8084/mqtt'; | |
| const TAROT_IMAGES_URL = 'https://raw.githubusercontent.com/metabismuth/tarot-json/refs/heads/master/tarot-images.json'; | |
| const CARDS_BASE_URL = 'https://raw.githubusercontent.com/metabismuth/tarot-json/refs/heads/master/cards/'; | |
| // 全局變量 | |
| let client = null; | |
| let clientId = null; | |
| let tarotCards = []; | |
| let isConnected = false; | |
| // 初始化 | |
| window.onload = function() { | |
| initMQTT(); | |
| loadTarotData(); | |
| }; | |
| // 加載塔羅牌數據(從 tarot-images.json) | |
| async function loadTarotData() { | |
| try { | |
| console.log('正在加載塔羅牌圖片數據...'); | |
| const response = await fetch(TAROT_IMAGES_URL); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| if (Array.isArray(data)) { | |
| tarotCards = data; | |
| } else if (data.cards && Array.isArray(data.cards)) { | |
| tarotCards = data.cards; | |
| } else { | |
| throw new Error('數據格式不正確'); | |
| } | |
| console.log('塔羅牌數據加載成功,共', tarotCards.length, '張牌'); | |
| console.log('第一張牌示例:', tarotCards[0]); | |
| } catch (error) { | |
| console.error('加載塔羅牌數據失敗:', error); | |
| showError('加載塔羅牌數據失敗,請刷新頁面重試'); | |
| } | |
| } | |
| // 初始化 MQTT 連接 | |
| function initMQTT() { | |
| clientId = 'client_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |
| const options = { | |
| clientId: clientId, | |
| clean: true, | |
| connectTimeout: 4000, | |
| reconnectPeriod: 1000, | |
| }; | |
| updateStatus('正在連接占卜伺服器...', 'connecting'); | |
| try { | |
| client = mqtt.connect(MQTT_BROKER, options); | |
| client.on('connect', function() { | |
| console.log('MQTT 連接成功'); | |
| isConnected = true; | |
| updateStatus('已連接占卜伺服器', 'connected'); | |
| const subscribeTopic = `${UNIQUE_PREFIX}/hy3/response/${clientId}`; | |
| client.subscribe(subscribeTopic, function(err) { | |
| if (!err) { | |
| console.log('訂閱主題成功:', subscribeTopic); | |
| } else { | |
| console.error('訂閱失敗:', err); | |
| } | |
| }); | |
| }); | |
| client.on('message', function(topic, message) { | |
| try { | |
| const data = JSON.parse(message.toString()); | |
| console.log('收到回應:', data); | |
| if (data.client_id === clientId) { | |
| handleLLMResponse(data); | |
| } | |
| } catch (error) { | |
| console.error('解析消息失敗:', error); | |
| } | |
| }); | |
| client.on('error', function(err) { | |
| console.error('MQTT 錯誤:', err); | |
| updateStatus('連接錯誤: ' + err.message, 'error'); | |
| isConnected = false; | |
| }); | |
| client.on('close', function() { | |
| console.log('MQTT 連接關閉'); | |
| isConnected = false; | |
| updateStatus('連接已關閉', 'error'); | |
| }); | |
| } catch (error) { | |
| console.error('MQTT 初始化失敗:', error); | |
| updateStatus('初始化失敗: ' + error.message, 'error'); | |
| } | |
| } | |
| // 更新狀態顯示 | |
| function updateStatus(message, type) { | |
| const statusEl = document.getElementById('status'); | |
| statusEl.textContent = message; | |
| statusEl.className = 'status show ' + type; | |
| } | |
| // 抽取塔羅牌 | |
| async function drawCard() { | |
| const question = document.getElementById('question').value.trim(); | |
| if (!question) { | |
| alert('請先輸入您的問題'); | |
| return; | |
| } | |
| if (!isConnected) { | |
| alert('尚未連接到占卜伺服器,請等待連接完成後再試'); | |
| return; | |
| } | |
| if (tarotCards.length === 0) { | |
| alert('塔羅牌數據尚未加載完成,請稍後再試'); | |
| return; | |
| } | |
| // 顯示加載動畫 | |
| document.getElementById('loading').classList.add('show'); | |
| document.getElementById('result').classList.remove('show'); | |
| document.getElementById('error').classList.remove('show'); | |
| document.getElementById('drawBtn').disabled = true; | |
| document.getElementById('reasoningContainer').innerHTML = ''; | |
| document.getElementById('interpretation').innerHTML = ''; | |
| try { | |
| const randomIndex = Math.floor(Math.random() * tarotCards.length); | |
| const selectedCard = tarotCards[randomIndex]; | |
| const isUpright = Math.random() > 0.5; | |
| console.log('選中的牌:', selectedCard); | |
| console.log('圖片文件名:', selectedCard.img); | |
| displayCard(selectedCard, isUpright); | |
| const position = isUpright ? '正位' : '逆位'; | |
| const requestData = { | |
| client_id: clientId, | |
| message: `問題:${question}\n\n抽到的牌:${selectedCard.name} (${position})\n牌組:${selectedCard.arcana}\n牌號:${selectedCard.number}\n\n請為這張牌進行詳細的占卜解讀,結合使用者的問題給出具體建議。請使用 Markdown 格式輸出,包含標題、重點標註、條列式建議等。` | |
| }; | |
| console.log('發送請求:', requestData); | |
| const publishTopic = `${UNIQUE_PREFIX}/hy3/request`; | |
| client.publish(publishTopic, JSON.stringify(requestData), { qos: 1 }, function(err) { | |
| if (err) { | |
| console.error('發布失敗:', err); | |
| showError('發送請求失敗,請稍後重試'); | |
| document.getElementById('loading').classList.remove('show'); | |
| document.getElementById('drawBtn').disabled = false; | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('抽牌錯誤:', error); | |
| showError('抽牌過程發生錯誤: ' + error.message); | |
| document.getElementById('loading').classList.remove('show'); | |
| document.getElementById('drawBtn').disabled = false; | |
| } | |
| } | |
| // 顯示牌面 | |
| function displayCard(card, isUpright) { | |
| const cardImg = document.getElementById('cardImg'); | |
| const cardName = document.getElementById('cardName'); | |
| const cardPosition = document.getElementById('cardPosition'); | |
| const cardElement = document.getElementById('cardImage'); | |
| const imgDebug = document.getElementById('imgDebug'); | |
| const imgFileName = card.img; | |
| const imgUrl = CARDS_BASE_URL + imgFileName; | |
| console.log('圖片文件名:', imgFileName); | |
| console.log('完整圖片 URL:', imgUrl); | |
| cardImg.src = imgUrl; | |
| cardImg.alt = card.name; | |
| cardImg.onerror = function() { | |
| console.error('圖片加載失敗:', imgUrl); | |
| showError('圖片加載失敗,請檢查網絡連接'); | |
| }; | |
| cardName.textContent = card.name; | |
| cardPosition.textContent = isUpright ? '✨ 正位 (Upright)' : '🔄 逆位 (Reversed)'; | |
| imgDebug.textContent = `Image: ${imgFileName}`; | |
| if (!isUpright) { | |
| cardElement.classList.add('reversed'); | |
| } else { | |
| cardElement.classList.remove('reversed'); | |
| } | |
| } | |
| // 處理 LLM 回應(使用 Markdown 解析) | |
| function handleLLMResponse(data) { | |
| document.getElementById('loading').classList.remove('show'); | |
| document.getElementById('drawBtn').disabled = false; | |
| if (data.status === 'success') { | |
| const interpretationEl = document.getElementById('interpretation'); | |
| const reasoningContainer = document.getElementById('reasoningContainer'); | |
| // 處理思考過程(可折疊) | |
| if (data.reasoning && data.reasoning.trim()) { | |
| const reasoningHtml = marked.parse(data.reasoning); | |
| reasoningContainer.innerHTML = ` | |
| <details class="reasoning-section"> | |
| <summary>💭 查看 AI 思考過程</summary> | |
| <div class="markdown-body">${reasoningHtml}</div> | |
| </details> | |
| `; | |
| } else { | |
| reasoningContainer.innerHTML = ''; | |
| } | |
| // 解析主要結果為 Markdown | |
| const resultHtml = marked.parse(data.result || ''); | |
| interpretationEl.innerHTML = resultHtml; | |
| document.getElementById('result').classList.add('show'); | |
| } else if (data.status === 'error') { | |
| showError('解讀失敗: ' + (data.error || '未知錯誤')); | |
| } else { | |
| showError('未知回應狀態: ' + data.status); | |
| } | |
| } | |
| // 顯示錯誤 | |
| function showError(message) { | |
| const errorEl = document.getElementById('error'); | |
| errorEl.textContent = message; | |
| errorEl.classList.add('show'); | |
| setTimeout(() => { | |
| errorEl.classList.remove('show'); | |
| }, 5000); | |
| } | |
| // 頁面卸載時清理 | |
| window.onbeforeunload = function() { | |
| if (client) { | |
| client.end(); | |
| } | |
| }; | |
| </script> | |
| </body> | |
| </html> |