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; | |
| } | |
| /* 牌義標題樣式 */ | |
| .card-title { | |
| font-size: 28px; | |
| font-weight: bold; | |
| color: #2a5298; | |
| margin-bottom: 20px; | |
| padding-bottom: 10px; | |
| border-bottom: 2px solid #667eea; | |
| } | |
| /* 牌義原文區塊樣式 */ | |
| .meaning-original { | |
| background: #f0f4ff; | |
| padding: 15px 20px; | |
| border-radius: 8px; | |
| border-left: 4px solid #667eea; | |
| margin-bottom: 20px; | |
| font-size: 14px; | |
| color: #444; | |
| line-height: 1.6; | |
| } | |
| .meaning-original strong { | |
| color: #2a5298; | |
| } | |
| .meaning-original .label { | |
| display: inline-block; | |
| background: #667eea; | |
| color: white; | |
| padding: 2px 10px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| margin-bottom: 6px; | |
| } | |
| </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 id="cardTitle" class="card-title"></div> | |
| <!-- 牌義原文區塊 --> | |
| <div id="meaningOriginal" class="meaning-original" style="display:none;"> | |
| <div class="label">📜 card.json 牌義原文</div> | |
| <div id="meaningOriginalText"></div> | |
| </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/'; | |
| const CARD_MEANINGS_URL = './card.json'; | |
| // 全局變量 | |
| let client = null; | |
| let clientId = null; | |
| let tarotCards = []; | |
| let cardMeanings = {}; | |
| let isConnected = false; | |
| let isDataLoaded = false; | |
| // 初始化 | |
| window.onload = function() { | |
| initMQTT(); | |
| loadAllData(); | |
| }; | |
| // 加載所有數據 | |
| async function loadAllData() { | |
| try { | |
| 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, '張牌'); | |
| 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 => { | |
| const cardName = card.name.trim(); | |
| cardMeanings[cardName] = { | |
| meaning_up: card.meaning_up || '(無正位解析)', | |
| meaning_rev: card.meaning_rev || '(無逆位解析)' | |
| }; | |
| if (cardName.startsWith('The ')) { | |
| const shortName = cardName.replace('The ', ''); | |
| cardMeanings[shortName] = cardMeanings[cardName]; | |
| } | |
| }); | |
| 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('收到完整回應:', JSON.stringify(data, null, 2)); | |
| 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; | |
| } | |
| // 獲取牌義 | |
| function getCardMeaning(cardName, isUpright) { | |
| const positionKey = isUpright ? 'meaning_up' : 'meaning_rev'; | |
| let meaning = cardMeanings[cardName]?.[positionKey]; | |
| if (meaning) return meaning; | |
| const withoutThe = cardName.replace(/^The /, ''); | |
| meaning = cardMeanings[withoutThe]?.[positionKey]; | |
| if (meaning) return meaning; | |
| const withThe = 'The ' + cardName; | |
| meaning = cardMeanings[withThe]?.[positionKey]; | |
| if (meaning) return meaning; | |
| const lowerName = cardName.toLowerCase(); | |
| for (const key of Object.keys(cardMeanings)) { | |
| if (key.toLowerCase() === lowerName || key.toLowerCase() === 'the ' + lowerName) { | |
| return cardMeanings[key][positionKey]; | |
| } | |
| } | |
| return '(未找到牌義)'; | |
| } | |
| // --- 過濾違規關鍵詞(防止 LLM 越界) --- | |
| function filterForbiddenTerms(text) { | |
| const forbiddenTerms = [ | |
| '甲狀腺', '自律神經', '纖維肌痛症', 'CBT', 'EMDR', | |
| '中醫', '針灸', '診斷', '處方', '用藥', | |
| 'fibromyalgia', 'cognitive behavioral therapy', 'eye movement desensitization', | |
| '慢性病初期', '心理疾患', '創傷後壓力症候群', 'PTSD' | |
| ]; | |
| let filtered = text; | |
| forbiddenTerms.forEach(term => { | |
| if (filtered.includes(term)) { | |
| filtered = filtered.replace(new RegExp(term, 'gi'), '【相關症狀】'); | |
| } | |
| }); | |
| return filtered; | |
| } | |
| // 抽牌 | |
| async function drawCard() { | |
| const question = document.getElementById('question').value.trim(); | |
| if (!question) { | |
| alert('請先輸入您的問題'); | |
| return; | |
| } | |
| if (!isDataLoaded) { | |
| alert('牌義數據尚未加載完成,請稍後再試'); | |
| return; | |
| } | |
| if (!isConnected) { | |
| 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 = ''; | |
| document.getElementById('cardTitle').innerHTML = ''; | |
| document.getElementById('meaningOriginal').style.display = 'none'; | |
| document.getElementById('meaningOriginalText').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 positionEmoji = isUpright ? '🌿' : '🔄'; | |
| document.getElementById('cardTitle').textContent = `${positionEmoji} ${selectedCard.name} ${position}`; | |
| const meaningText = getCardMeaning(selectedCard.name, isUpright); | |
| console.log('牌義內容:', meaningText); | |
| // 顯示牌義原文(供使用者對照) | |
| const meaningOriginalEl = document.getElementById('meaningOriginal'); | |
| const meaningOriginalTextEl = document.getElementById('meaningOriginalText'); | |
| meaningOriginalTextEl.textContent = meaningText; | |
| meaningOriginalEl.style.display = 'block'; | |
| // --- 最終版 Prompt:結合問題與牌義,使用 Mistral 模型 --- | |
| const systemPrompt = `你是一位資深的塔羅占卜師,擁有20年的實務經驗,專注於將塔羅智慧轉化為具體、溫暖且實用的生活指引。 | |
| --- | |
| 【你的任務】 | |
| 使用者提出了一個具體問題,並抽到了一張塔羅牌。請根據牌義,為使用者提供**完整、深刻、有溫度**的解析,幫助他們獲得清晰的視角與可行的方向。 | |
| --- | |
| 【核心原則】 | |
| 1. **忠於牌義,但靈活應用**:解析內容必須源自 card.json 的牌義原文,但要將牌義轉化為針對使用者問題的具體觀察與建議。不要只是翻譯牌義,而是要「活用」它。 | |
| 2. **保持開放性**:塔羅占卜是指引,不是預言。避免使用「一定會」、「絕對是」、「注定」等武斷用語。改用「可能意味著」、「這暗示」、「可以考慮」等開放性表達。 | |
| 3. **不做醫療診斷**:不得指定任何具體疾病名稱、治療方式或藥物建議。若涉及健康問題,請溫和提醒使用者諮詢專業醫師。 | |
| 4. **不指控他人**:不得將牌義解讀為「他人陰謀」、「刻意隱瞞」或「家庭衝突」等外部指控。聚焦於當事人自身的能量與選擇。 | |
| 5. **語氣溫暖、同理**:使用者可能正在經歷困惑或焦慮,請用溫和、支持的語氣回應,避免恐嚇或過度嚴肅的語言。 | |
| --- | |
| 【輸出格式要求】 | |
| 請嚴格按照以下結構輸出,確保解析**完整、有深度、具體可行**: | |
| --- | |
| **🔍 核心訊息:牌義的精華提煉** | |
| (約 150-200 字) | |
| - 用 2-3 句話提煉這張牌的核心意涵 | |
| - 直接連結到使用者的問題情境 | |
| - 點出這個牌義對當事人最重要的提示是什麼 | |
| --- | |
| **💭 心理與情緒層面:內在世界的探索** | |
| (約 200-250 字) | |
| - 當事人可能正在經歷什麼樣的內在狀態(焦慮、期待、恐懼、矛盾等)? | |
| - 牌義如何呼應或映照這些情緒? | |
| - 這張牌邀請當事人如何看待自己的情緒? | |
| --- | |
| **🌱 行動建議:具體可行的方向** | |
| (約 250-300 字) | |
| - 給出 3-4 個具體、可執行的建議 | |
| - 每個建議都要有「為什麼這樣做」的牌義依據 | |
| - 避免空泛的建議(如「放鬆就好」),要具體(如「每天留 15 分鐘做一個小決定,練習主動感」) | |
| --- | |
| **💎 結語:一句溫暖的收尾** | |
| (約 50-80 字) | |
| - 用一句有力量、有溫度的話語總結 | |
| - 給予當事人信心與希望 | |
| - 不超過 3 句話 | |
| --- | |
| 【格式規範】 | |
| - 請使用繁體中文輸出 | |
| - 每個面向的標題請使用粗體(**) | |
| - 總字數請控制在 700-1000 字之間 | |
| - 直接輸出解析內容,不要加「好的,以下是您的解析」之類的開場白 | |
| - 不要在結尾加「如果您需要進一步討論...」等延伸邀請 | |
| --- | |
| 【範例參考】(僅供語氣參考,請勿複製內容) | |
| 使用者的問題是:「我最近在工作上感到停滯,不知道該不該換工作?」 | |
| 核心訊息可以這樣開頭:「這張牌告訴你,停滯不是終點,而是轉折前的寧靜。你感受到的瓶頸,其實是內在正在重新校準方向的訊號...」 | |
| 心理層面可以這樣展開:「你可能正在兩種聲音之間拉扯——一邊是對穩定的渴望,另一邊是對成長的嚮往。這種矛盾不是弱點,而是你內心正在成長的證明...」 | |
| 行動建議可以這樣具體化:「第一,為自己設定一個 90 天的觀察期,記錄每天工作中讓你感到充實與空虛的時刻...」 | |
| 結語可以這樣收尾:「你不需要今天就知道答案,但你可以從今天開始,為自己創造更多選擇的空間。」 | |
| --- | |
| 【現在請開始】 | |
| 使用者問題:${question} | |
| 抽到的牌:${selectedCard.name}(${position}) | |
| 牌義原文:${meaningText} | |
| 請根據以上資訊,為這位使用者提供完整、深刻的塔羅解析。`; | |
| const requestData = { | |
| client_id: clientId, | |
| message: `【牌名】${selectedCard.name}(${position})\n\n【牌義】${meaningText}\n\n【問題】${question}\n\n${systemPrompt}` | |
| }; | |
| console.log('發送請求給 Mistral 模型(結合問題模式)'); | |
| 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; | |
| 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) { | |
| 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 = ''; | |
| } | |
| let resultContent = data.result || ''; | |
| if (Array.isArray(resultContent)) { | |
| console.log('檢測到陣列回應,長度:', resultContent.length); | |
| resultContent = resultContent[resultContent.length - 1] || ''; | |
| } | |
| if (typeof resultContent === 'string') { | |
| // 如果回應包含完整的三個面向,保留全部 | |
| // 否則嘗試提取有效內容 | |
| const validPatterns = [ | |
| /(\*\*🔍 核心訊息\*\*[\s\S]*?)(?=\*\*💭 心理與情緒層面\*\*|$)/, | |
| /(\*\*💭 心理與情緒層面\*\*[\s\S]*?)(?=\*\*🌱 行動建議\*\*|$)/, | |
| /(\*\*🌱 行動建議\*\*[\s\S]*?)(?=---|$)/ | |
| ]; | |
| // 檢查是否包含三個面向 | |
| const hasAllSections = resultContent.includes('核心訊息') && | |
| resultContent.includes('心理與情緒') && | |
| resultContent.includes('行動建議'); | |
| if (!hasAllSections && resultContent.length > 100) { | |
| // 嘗試提取 Markdown 標題內容 | |
| const mdMatch = resultContent.match(/(\*\*[^*]+\*\*[\s\S]*?)(?=\n\n|$)/); | |
| if (mdMatch && mdMatch[0].length > 50) { | |
| resultContent = mdMatch[0]; | |
| } | |
| } | |
| // 過濾違規關鍵詞 | |
| resultContent = filterForbiddenTerms(resultContent); | |
| resultContent = resultContent.replace(/['"\]\s]+$/, '').trim(); | |
| resultContent = resultContent.replace(/^['"\s]+/, '').trim(); | |
| resultContent = resultContent.replace(/\\n/g, '\n'); | |
| const codeBlockMatch = resultContent.match(/```(?:markdown|md)?\s*([\s\S]*?)```/); | |
| if (codeBlockMatch) { | |
| resultContent = codeBlockMatch[1].trim(); | |
| } | |
| } | |
| console.log('最終解析內容:', resultContent.substring(0, 200) + '...'); | |
| // 如果結果為空或太短,顯示牌義原文並附註 | |
| if (!resultContent || resultContent.length < 20) { | |
| const originalText = document.getElementById('meaningOriginalText').textContent; | |
| resultContent = `**⚠️ 解析生成中**\n\n牌義原文:${originalText}\n\n請根據上述牌義對照實際情況。`; | |
| } | |
| const resultHtml = marked.parse(resultContent || '(無解析內容)'); | |
| 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> |