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, | |
| gfm: true, | |
| headerIds: true, | |
| 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/'; | |
| // --- 修改點 1: 從您的 card.json 檔案讀取牌義資料 --- | |
| const CARD_MEANINGS_URL = './card.json'; // 請確保 card.json 與此 HTML 檔案放在同一目錄下 | |
| // 全局變量 | |
| let client = null; | |
| let clientId = null; | |
| let tarotCards = []; | |
| let cardMeanings = {}; // 儲存從 card.json 讀取的牌義 | |
| let isConnected = false; | |
| let isDataLoaded = false; | |
| // 初始化 | |
| window.onload = function() { | |
| initMQTT(); | |
| loadAllData(); | |
| }; | |
| // 加載所有數據(塔羅牌圖片資訊 + 牌義) | |
| async function loadAllData() { | |
| try { | |
| // 1. 加載塔羅牌圖片資訊 (tarot-images.json) | |
| console.log('正在加載塔羅牌圖片數據...'); | |
| const imageResponse = await fetch(TAROT_IMAGES_URL); | |
| if (!imageResponse.ok) { | |
| throw new Error(`HTTP error! status: ${imageResponse.status}`); | |
| } | |
| const imageData = await imageResponse.json(); | |
| if (imageData.cards && Array.isArray(imageData.cards)) { | |
| tarotCards = imageData.cards; | |
| } else { | |
| throw new Error('圖片數據格式不正確'); | |
| } | |
| console.log('圖片數據加載成功,共', tarotCards.length, '張牌'); | |
| // 2. 加載牌義數據 (card.json) | |
| console.log('正在加載牌義數據...'); | |
| const meaningResponse = await fetch(CARD_MEANINGS_URL); | |
| if (!meaningResponse.ok) { | |
| throw new Error(`HTTP error! status: ${meaningResponse.status}`); | |
| } | |
| const meaningData = await meaningResponse.json(); | |
| if (meaningData.cards && Array.isArray(meaningData.cards)) { | |
| // 建立以牌名為索引的牌義查找表 | |
| meaningData.cards.forEach(card => { | |
| cardMeanings[card.name] = { | |
| meaning_up: card.meaning_up || '(無正位解析)', | |
| meaning_rev: card.meaning_rev || '(無逆位解析)' | |
| }; | |
| }); | |
| console.log('牌義數據加載成功,共', Object.keys(cardMeanings).length, '筆'); | |
| } else { | |
| throw new Error('牌義數據格式不正確'); | |
| } | |
| isDataLoaded = true; | |
| console.log('所有數據加載完成!'); | |
| } catch (error) { | |
| console.error('數據加載失敗:', error); | |
| showError('數據加載失敗,請確認 card.json 檔案是否存在:' + error.message); | |
| } | |
| } | |
| // 初始化 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; | |
| } | |
| // --- 修改點 2: 改寫抽牌邏輯,直接從 cardMeanings 讀取牌義 --- | |
| async function drawCard() { | |
| const question = document.getElementById('question').value.trim(); | |
| if (!question) { | |
| alert('請先輸入您的問題'); | |
| return; | |
| } | |
| if (!isDataLoaded) { | |
| 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); | |
| displayCard(selectedCard, isUpright); | |
| const position = isUpright ? '正位' : '逆位'; | |
| const positionKey = isUpright ? 'meaning_up' : 'meaning_rev'; | |
| // --- 從 cardMeanings 中查找牌義 --- | |
| let meaningText = '(未找到牌義)'; | |
| if (cardMeanings[selectedCard.name]) { | |
| meaningText = cardMeanings[selectedCard.name][positionKey] || '(無此牌義)'; | |
| } else { | |
| // 嘗試用 "The " 開頭的牌名再找一次(因為 card.json 的牌名可能帶有 "The ") | |
| const altName = selectedCard.name.startsWith('The ') ? selectedCard.name.substring(4) : 'The ' + selectedCard.name; | |
| if (cardMeanings[altName]) { | |
| meaningText = cardMeanings[altName][positionKey] || '(無此牌義)'; | |
| } else { | |
| // 嘗試用原始名稱的替代形式 | |
| const simpleName = selectedCard.name.replace(/^The /, ''); | |
| if (cardMeanings[simpleName]) { | |
| meaningText = cardMeanings[simpleName][positionKey] || '(無此牌義)'; | |
| } | |
| } | |
| } | |
| // --- 生成結果 HTML (將牌義直接展示,不透過 LLM) --- | |
| const resultHtml = ` | |
| <h2>🃏 ${selectedCard.name} (${position})</h2> | |
| <p><strong>牌組:</strong>${selectedCard.arcana} ${selectedCard.suit ? '· ' + selectedCard.suit : ''}</p> | |
| <hr> | |
| <h3>📜 牌義解析</h3> | |
| <blockquote>${meaningText}</blockquote> | |
| <hr> | |
| <h3>💡 結合問題的建議</h3> | |
| <p>請靜心思考這張牌帶給您的啟示。${position === '正位' ? '正位通常象徵開放、積極的能量,鼓勵您擁抱當下的機會。' : '逆位則提醒您注意潛在的阻礙或內在的反思,或許需要調整心態或行動。'}</p> | |
| <p>您的問題是:<em>「${question}」</em></p> | |
| <p>試著將牌義與您當前的處境連結,相信直覺會引導您找到答案。</p> | |
| `; | |
| // 顯示結果 | |
| const interpretationEl = document.getElementById('interpretation'); | |
| interpretationEl.innerHTML = resultHtml; | |
| // 清除思考過程(因為不再需要 LLM) | |
| document.getElementById('reasoningContainer').innerHTML = ''; | |
| document.getElementById('result').classList.add('show'); | |
| 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; | |
| 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 回應 (保留,但現在不會被調用,因為已不再發送請求) | |
| function handleLLMResponse(data) { | |
| // 此函數不再使用,但保留以避免錯誤 | |
| console.log('handleLLMResponse 被調用,但現在已不使用 LLM。'); | |
| } | |
| // 顯示錯誤 (保持不變) | |
| 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> |