✦
6 位專家 · 準備討論
系統檢查中
視圖
回應方式
◉
全員獨立回答 每位啟用中的 AI 只參考你的訊息與自己的歷史,不會先看其他 AI 的答案。

你的 AI 決策圓桌

一次召集六位 AI 專家,讓不同觀點在同一個工作空間裡快速收斂。

或本機展示環境
// (見 staging/FRONTEND-CONFIG.md)。未設定時拒絕啟動並明確報錯,不靜默用預設值、 // 不回退 localhost、不回退正式 Worker。指向正式 Production Worker 時亦拒絕啟動。 // 注意:正式 Production URL 字串在本檔只能出現在下方守衛拒絕名單這一處。 const PRODUCTION_WORKER_URL = 'https://ai-office-worker.5609wa.workers.dev'; const WORKER_URL = (typeof window !== 'undefined' && typeof window.__STAGING_WORKER_URL__ === 'string') ? window.__STAGING_WORKER_URL__.trim().replace(/\/+$/, '') : ''; if (!WORKER_URL) { document.body.innerHTML = '

Staging 未啟動

未設定 WORKER_URL:請由部署時注入 window.__STAGING_WORKER_URL__(Staging Worker origin)。未設定時拒絕啟動,不使用預設值。

'; throw new Error('Staging 未設定 WORKER_URL:請由部署時注入 window.__STAGING_WORKER_URL__'); } if (WORKER_URL === PRODUCTION_WORKER_URL) { document.body.innerHTML = '

Staging 未啟動

WORKER_URL 不可指向正式 Production Worker。請改用 Staging Worker origin。

'; throw new Error('Staging WORKER_URL 不可指向正式 Production Worker'); } // [STAGING-WORKER-URL-GUARD-END] const firebaseConfig = { apiKey: "AIzaSyAgszrqJPmcTee9cIr0zbprr37DtDyIkKU", authDomain: "ai-office-77e1a.firebaseapp.com", projectId: "ai-office-77e1a", storageBucket: "ai-office-77e1a.firebasestorage.app", messagingSenderId: "1050561835656", appId: "1:1050561835656:web:008dbadc924e4b9d15253d" }; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); const provider = new GoogleAuthProvider(); const EMPLOYEES = [ { id: 'gemini', name: 'Gemini', role: '策略顧問', color: '#4285f4', initial: 'G', fileSupport: 'all' }, { id: 'groq', name: 'Groq', role: '即時分析', color: '#f97316', initial: 'Q', fileSupport: 'none' }, { id: 'mistral', name: 'Mistral', role: '邏輯專家', color: '#10b981', initial: 'M', fileSupport: 'image' }, { id: 'cloudflare', name: 'CF AI', role: '效率顧問', color: '#f59e0b', initial: 'C', fileSupport: 'none' }, { id: 'openrouter', name: 'OpenRouter', role: '多模型顧問', color: '#7e22ce', initial: 'O', fileSupport: 'none' }, { id: 'gemma', name: 'Gemma', role: '創新顧問', color: '#f87171', initial: 'G', fileSupport: 'none' } ]; let currentUser = null, currentRoom = null, currentMode = 'all', viewMode = 'standard', targetAI = 'gemini'; let activeEmployees = new Set(EMPLOYEES.map(e => e.id)); let conversationHistory = [], rooms = [], roundIndex = 0, selectedRounds = 2, selectedDiscussionMode = 'parallel', unsub = null; let pendingFileBase64 = null, pendingFileMime = null, isRecording = false, isSending = false, cancelRequested = false; let roomEditorMode = 'create', isRoomMutationBusy = false, editingEmployeeId = null; const activeRequestControllers = new Set(); // [Staging B-17] 第一次真實驗收全部關閉自動重試:只嘗試一次(生產 V1.2.1 為 2 次)。 // 生產行為不動(見 production 對照);Staging 的 respondOne 迴圈因此只跑一次,失敗即拋錯。 const AI_REQUEST_ATTEMPTS = 1; const AI_REQUEST_DEADLINE_MS = 38000; const AI_ATTEMPT_TIMEOUT_MS = 34000; const MAX_HISTORY_MESSAGES = 36; const MAX_HISTORY_CHARACTERS = 52000; const employeeRuntimeStatus = new Map(EMPLOYEES.map(employee => [employee.id, { state: 'idle', label: '已啟用' }])); const TECHNICAL_FAILURE_PATTERN = /^\s*(?:(無回應)|專家目前沒有回應。|⚠️ .*回應超過 45 秒|here(?:'|’)s (?:a |the )?(?:thinking|reasoning) process\b|here is (?:a |the )?(?:thinking|reasoning) process\b||(?:[*-]\s*)*(?:user asks|constraint checklist|drafting\s*(?:\(internal\))?|current date)\s*:)/i; // ===== 本機受控展示臨時身分 (Demo Visitor) ===== const DEMO_USER = Object.freeze({ uid: 'demo-local-visitor', displayName: '本地訪客 (Demo 模式)', email: 'demo@local.internal', isDemo: true, getIdToken: async () => 'demo-local-token' }); const localDemoState = { rooms: [ { id: 'demo-room-default', name: 'AI 圓桌決策模擬 (Demo)', _activityAt: Date.now() } ], messages: { 'demo-room-default': [] } }; // ===== 本機 Demo Mock 回覆產生器(V1.2 任務 1:修復本機 Demo)===== // 僅 Demo、僅本機:不呼叫 Worker、不取 Firebase token、不寫 Firestore。 // 每則開頭 [Mock-],內容依席位視角區分,並誠實標示為模擬回覆。 const LOCAL_MOCK_LENSES = { gemini: { stance: '先釐清目標與利害關係人,再決定資源配置與優先順序。', view: '多數分歧來自目標不一致,而非方案本身好壞;應先對齊成功標準。', action: '列出三個可衡量的成功指標,並按影響力排序下一步。', replyAngle: '策略上我同意先對齊目標,但要補一個缺口:若成功標準未定,任何方案都無法比較優劣。' }, groq: { stance: '先挑戰最樂觀的假設,找出這個做法在什麼條件下會失敗。', view: '速度再快也救不了方向錯誤;最危險的不是慢,而是沒人指出盲點。', action: '列出兩個最可能出錯的環節,並為每個環節準備一個停損條件。', replyAngle: '反方提醒:前述主張假設了執行一切順利,我要指出一個被低估的代價與一個失敗條件。' }, mistral: { stance: '先檢查因果鏈:結論是否真的由提出的證據支持。', view: '許多建議把相關誤當成因果;沒有可驗證的數字,就不該當成事實推進。', action: '把關鍵主張拆成可驗證的檢查點,並標示哪些資訊目前仍缺。', replyAngle: '邏輯上我要追問:前述主張的證據是否充分?有一個推論跳步,需要補上驗證方式。' }, cloudflare: { stance: '先算成本與執行順序:以最小資源換取最快可見的成果。', view: '再好的方案若排程與人力撐不住,就只是紙上計畫;落地順序本身就是策略。', action: '把工作拆成兩週內可交付的第一步,並估算所需人力與成本上限。', replyAngle: '效率上我要補強:前述方案的執行成本被低估了,我建議先做成本最低、驗證最快的那一步。' }, openrouter: { stance: '先攤開替代路徑再選,不要只比較單一方案的優缺點。', view: '不同情境適合不同做法;折衷方案往往比極端選項更能兼顧各方。', action: '提出兩個替代路徑並比較適用情境、代價與退場方式。', replyAngle: '多元方案的角度:除了前述主張,我要補一個折衷路徑,它在風險與效益之間取得不同的平衡。' }, gemma: { stance: '先找一個小規模、低風險、可量化的實驗來驗證想法。', view: '創新不必一次到位;一次小實驗的結果,比長時間的紙上爭論更有價值。', action: '設計一個一週內可完成的小實驗,並訂出明確的成功與放棄標準。', replyAngle: '創新的角度:我不反對前述方向,但建議先用一個小實驗驗證最不確定的假設,再決定是否擴大投入。' } }; // 以回覆簽名記錄哪些回覆是 Mock,供 deferSave 的辯論流程取回旗標用。 const mockReplySignatures = new Map(); function recordMockReply(reply) { const signature = normalizeReplySignature(reply); if (signature) mockReplySignatures.set(signature, true); } function isMockReplyText(reply) { if (typeof reply !== 'string' || !reply) return false; if (/^\s*\[Mock-[^\]]+\]/.test(reply)) return true; const signature = normalizeReplySignature(reply); return signature ? mockReplySignatures.get(signature) === true : false; } function parseMockReplyAuthor(reply) { const match = String(reply || '').match(/\[([^\]\[]+)\]/); if (!match) return null; const id = match[1].replace(/^Mock-/, ''); const employee = EMPLOYEES.find(item => item.id === id); return employee ? employee.name : match[1]; } function extractDemoTopic(text) { const raw = String(text || ''); const themed = raw.match(/討論主題[::「]\s*「?\s*([^」\n]{1,120})/); if (themed) return themed[1].trim(); return raw.split('\n')[0].trim().slice(0, 120); } // 本機 Mock 回覆產生器。kind: 'statement'(Round 1 立論)| 'reply'(Round 2 回應) // | 'summary'(總結);round 為輪次數字或 'summary'。targetName 為 Round 2 要回應的對象。 function generateLocalMockReply(aiId, topic, round, targetName, previousReplies, kind = 'debate') { const employee = EMPLOYEES.find(item => item.id === aiId) || { id: aiId, name: aiId, role: 'AI 專家' }; const lens = LOCAL_MOCK_LENSES[aiId] || LOCAL_MOCK_LENSES.gemini; const cleanTopic = String(topic || '').replace(/\s+/g, ' ').trim().slice(0, 120) || '本次議題'; const note = '(本則為本機模擬回覆,非真實 AI 生成,僅供體驗流程與版面。)'; const header = `[Mock-${employee.id}]`; if (kind === 'summary' || round === 'summary') { // V1.2.1 修復 1:只列出傳入的成功發言中實際出現的作者。 // 發言可能是原始 Mock 回覆「[Mock-] …」或 debateHistory 形式「[<姓名>] [Mock-] …」, // 逐一掃描所有 [...] 標記,優先取能對上 EMPLOYEES id 或姓名者;未發言者不列入、不編造主張。 const authors = (Array.isArray(previousReplies) ? previousReplies : []) .flatMap(reply => { const tokens = [...String(reply || '').matchAll(/\[([^\]\[]+)\]/g)].map(match => match[1]); for (const token of tokens) { const byId = EMPLOYEES.find(item => item.id === token.replace(/^Mock-/, '')); if (byId) return [byId.name]; const byName = EMPLOYEES.find(item => item.name === token); if (byName) return [byName.name]; } return tokens.length > 0 ? [tokens[0]] : []; }).filter(Boolean); const uniqueAuthors = [...new Set(authors)]; const scope = uniqueAuthors.length > 0 ? `本總結僅整合實際發言的模擬席位:${uniqueAuthors.join('、')},未發言者不列入。` : '本場尚無其他模擬發言可整合,以下為就議題本身的模擬整理。'; return `${header} 【模擬總結|${employee.name}】針對「${cleanTopic}」:共識是先釐清目標與限制再行動;分歧在於速度、成本與創新幅度的取捨;下一步是選一個最小可行方案試行並訂出檢核點。${scope}${note}`; } if (kind === 'reply' || Number(round) >= 2) { const target = targetName || parseMockReplyAuthor((previousReplies || [])[0]) || '前述主張'; return `${header} 【回應|${employee.name}】回應「${target}」的主張:${lens.replyAngle}就「${cleanTopic}」而言,我的補強是:${lens.action}${note}`; } return `${header} 【立場|${employee.name}】針對「${cleanTopic}」:${lens.stance}獨有觀點:${lens.view}具體做法:${lens.action}${note}`; } async function checkSystemHealth() { const badge = document.getElementById('system-health'); const label = document.getElementById('system-health-text'); if (!badge || !label) return; badge.dataset.state = 'checking'; label.textContent = '系統檢查中'; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); try { const response = await fetch(`${WORKER_URL}/api/ping?ts=${Date.now()}`, { cache: 'no-store', signal: controller.signal }); const data = await response.json(); if (data?.status === 'degraded') { const unavailable = Object.entries(data.providers || {}).filter(([, ready]) => !ready).map(([id]) => id); badge.dataset.state = 'offline'; badge.title = unavailable.length > 0 ? `尚未完成設定:${unavailable.join('、')}` : '部分 AI 尚未完成設定'; label.textContent = `設定不完整 ${data.configuredCount || 0}/${data.providerCount || 6}`; // V1.2 Mock 警示(唯讀顯示):Mock 由伺服器端 MOCK_MODE 決定,前端無法啟動。 if (data?.mock === true) { badge.title += ';⚠ Mock 模式已啟用:回覆為離線模擬,非真實 AI'; label.textContent += ' ⚠ Mock 模式'; } return; } if (!response.ok || data?.ok !== true) throw new Error('health check failed'); badge.dataset.state = 'online'; badge.title = `服務版本:${data.build || 'unknown'};AI 設定:${data.configuredCount || 6}/${data.providerCount || 6}`; label.textContent = `系統在線 ${data.configuredCount || 6}/${data.providerCount || 6}`; // V1.2 Mock 警示(唯讀顯示):Mock 由伺服器端 MOCK_MODE 決定,前端無法啟動。 if (data?.mock === true) { badge.title += ';⚠ Mock 模式已啟用:回覆為離線模擬,非真實 AI'; label.textContent += ' ⚠ Mock 模式'; } } catch { badge.dataset.state = 'offline'; badge.title = 'Worker 健康檢查未通過'; label.textContent = '系統異常'; } finally { clearTimeout(timer); } } checkSystemHealth(); setInterval(checkSystemHealth, 120000); onAuthStateChanged(auth, async (user) => { if (user) { currentUser = user; document.getElementById('login-screen').style.display = 'none'; document.getElementById('user-name').textContent = user.displayName || user.email || '使用者'; document.getElementById('logout-btn').style.display = 'block'; if (window.matchMedia('(max-width: 768px)').matches) { document.getElementById('app').classList.add('sidebar-collapsed'); } try { renderEmployees(); await loadRooms(); } catch (error) { console.error(error); showToast('討論空間載入失敗,請重新整理後再試。', 'error', 7000); } } else { // V1.2.1 修復 2:Demo 沒有真實 Firebase 會話,Firebase 以 user=null 回報時 // 不得清除 Demo 狀態;僅真實使用者登出(currentUser 非 Demo)時才走下方清除流程。 if (currentUser?.isDemo) return; if (isSending) cancelActiveRequests(); currentUser = null; currentRoom = null; rooms = []; conversationHistory = []; if (unsub) { unsub(); unsub = null; } renderRoomControls(); document.getElementById('messages').innerHTML = ''; document.getElementById('login-screen').style.display = 'flex'; document.getElementById('logout-btn').style.display = 'none'; } }); function setLoginState(loading, message = '') { const button = document.getElementById('google-login-btn'); const error = document.getElementById('login-error'); if (button) { button.disabled = loading; button.textContent = loading ? '正在連接 Google…' : '使用 Google 帳號繼續'; } if (error) { error.textContent = message; error.classList.toggle('visible', Boolean(message)); } } window.loginWithGoogle = async () => { setLoginState(true); try { await signInWithPopup(auth, provider); } catch (error) { if (['auth/popup-blocked', 'auth/operation-not-supported-in-this-environment', 'auth/web-storage-unsupported'].includes(error.code)) { try { await signInWithRedirect(auth, provider); return; } catch (redirectError) { console.error(redirectError); setLoginState(false, 'Google 登入目前無法開啟,請允許重新導向或改用其他瀏覽器。'); return; } } if (error.code === 'auth/popup-closed-by-user' || error.code === 'auth/cancelled-popup-request') { setLoginState(false); return; } console.error(error); if (error.code === 'auth/unauthorized-domain') { setLoginState(false, `此網域未列入 Firebase 授權名單 (${error.code})。本機請使用下方「本機體驗 (Demo)」入口。`); } else if (error.code === 'auth/network-request-failed') { setLoginState(false, `無法連接 Firebase 認證伺服器 (${error.code})。離線環境請使用下方「本機體驗 (Demo)」入口。`); } else { setLoginState(false, `登入失敗 (${error.code || 'UNKNOWN'}): ${error.message || '請確認後再試一次'}。或點選下方「本機體驗 (Demo)」。`); } return; } setLoginState(false); }; window.loginAsDemo = async () => { currentUser = DEMO_USER; document.getElementById('login-screen').style.display = 'none'; const nameEl = document.getElementById('user-name'); if (nameEl) { nameEl.innerHTML = `本地訪客 DEMO`; } const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) { logoutBtn.style.display = 'block'; logoutBtn.textContent = '退出 Demo'; } if (window.matchMedia('(max-width: 768px)').matches) { document.getElementById('app').classList.add('sidebar-collapsed'); } renderEmployees(); await loadRooms(); showToast('已進入本機 Demo:六位專家的回覆皆為本機模擬內容(標示 Mock),非真實 AI 回覆,不會連線遠端 Worker 或寫入雲端。', 'info', 5000); }; getRedirectResult(auth).catch(error => { console.error(error); setLoginState(false, 'Google 登入未完成,請再試一次。'); }); window.logout = async () => { if (currentUser?.isDemo) { if (isSending) cancelActiveRequests(); currentUser = null; currentRoom = null; rooms = []; conversationHistory = []; if (unsub) { unsub(); unsub = null; } renderRoomControls(); document.getElementById('messages').innerHTML = ''; document.getElementById('login-screen').style.display = 'flex'; document.getElementById('logout-btn').style.display = 'none'; showToast('已退出本機 Demo 模式。', 'info'); return; } try { await signOut(auth); } catch (error) { console.error(error); showToast('登出失敗,請稍後再試。', 'error'); } }; window.switchViewMode = (mode, btn) => { viewMode = mode; const appEl = document.getElementById('app'); document.querySelectorAll('#btn-standard, #btn-meeting').forEach(b => b.classList.remove('active')); btn.classList.add('active'); clearMeetingSpeechPlayback(true); document.body.classList.toggle('meeting-active', mode === 'meeting'); if (mode === 'meeting') { appEl.classList.add('view-meeting'); renderMeetingSeats(); } else { appEl.classList.remove('view-meeting'); } }; window.toggleSidebar = () => document.getElementById('app').classList.toggle('sidebar-collapsed'); function getCustomNames() { try { return JSON.parse(localStorage.getItem('customNames') || '{}'); } catch { return {}; } } function updateMeetingTopic(text) { const topic = document.getElementById('meeting-topic-text'); if (!topic) return; const clean = String(text || '').replace(/\s+/g, ' ').trim(); topic.textContent = clean ? (clean.length > 76 ? clean.slice(0, 76) + '…' : clean) : '等待你提出會議議題'; } window.setActiveSeat = (id) => { document.querySelectorAll('.seat.active').forEach(item => item.classList.remove('active')); const seat = document.getElementById('seat-' + id); if (seat) seat.classList.add('active'); const employee = EMPLOYEES.find(item => item.id === id); const speaker = document.getElementById('meeting-feed-speaker'); const feed = document.getElementById('meeting-feed-text'); if (speaker) speaker.textContent = employee ? employee.name + ' 思考中' : '專家思考中'; if (feed) feed.textContent = '正在整理資料與觀點,完成後會立即顯示。'; }; function renderMeetingSeats() { const container = document.getElementById('conference-seats'); container.innerHTML = ''; const customNames = getCustomNames(); const width = container.clientWidth || 1000; const height = container.clientHeight || 560; const radiusX = Math.min(405, width * 0.38); const radiusY = Math.min(205, height * 0.34); const centerX = width / 2; const centerY = height * 0.48; const activeCount = EMPLOYEES.filter(employee => activeEmployees.has(employee.id)).length; const count = document.getElementById('meeting-participant-count'); if (count) count.textContent = String(activeCount); const latestTopic = [...conversationHistory].reverse().find(message => message.role === 'user'); updateMeetingTopic(latestTopic?.content || ''); EMPLOYEES.forEach((emp, i) => { const angle = -Math.PI / 2 + (Math.PI * 2 * i / EMPLOYEES.length); const x = centerX + radiusX * Math.cos(angle); const y = centerY + radiusY * Math.sin(angle); const seat = document.createElement('div'); const seatPosition = i === 0 ? 'top' : (i === 3 ? 'bottom' : (i < 3 ? 'right' : 'left')); seat.className = 'seat seat-pos-' + seatPosition + ' ' + (activeEmployees.has(emp.id) ? '' : 'is-muted'); seat.id = 'seat-' + emp.id; seat.dataset.seatIndex = String(i); seat.dataset.state = (employeeRuntimeStatus.get(emp.id) || { state: 'idle' }).state; seat.style.left = String(x - 77) + 'px'; seat.style.top = String(y - 31) + 'px'; seat.style.setProperty('--seat-color', emp.color); const nickname = customNames[emp.id]; const displayName = nickname ? nickname + ' (' + emp.name + ')' : emp.name; seat.innerHTML = '
' + emp.initial + '
' + escapeHtml(displayName) + '
' + emp.role + '
'; container.appendChild(seat); }); } function setEmployeeRuntimeStatus(id, state, label) { employeeRuntimeStatus.set(id, { state, label }); const status = document.querySelector(`[data-employee-id="${id}"] .emp-status`); if (!status) return; status.dataset.state = state; const text = status.querySelector('.emp-status-text'); if (text) text.textContent = label; const seat = document.getElementById('seat-' + id); if (seat) seat.dataset.state = state; } window.renderEmployees = () => { const el = document.getElementById('employee-list'); const customNames = getCustomNames(); const active = EMPLOYEES.filter(e => activeEmployees.has(e.id)); const count = document.querySelector('.expert-count'); if (count) count.textContent = String(active.length); el.innerHTML = EMPLOYEES.map((e, idx) => { const nickname = customNames[e.id]; const displayName = nickname ? `${nickname} (${e.name})` : e.name; const isSelected = activeEmployees.has(e.id); let modeClass = ''; if (currentMode === 'target' && targetAI === e.id) modeClass = 'target-active'; else if (currentMode === 'round' && isSelected) { const activeIdx = active.findIndex(a => a.id === e.id); if (activeIdx !== -1 && activeIdx === (roundIndex % active.length)) modeClass = 'round-active'; } const runtimeStatus = employeeRuntimeStatus.get(e.id) || { state: 'idle', label: '已啟用' }; return `
${e.initial}
${escapeHtml(displayName)}
${e.role} · ${runtimeStatus.label}
`; }).join(''); }; window.handleEmployeeClick = (id) => { if (currentMode === 'target') { targetAI = id; renderEmployees(); updateTargetDisplay(); updateModeContext(); } else { if (activeEmployees.has(id)) activeEmployees.delete(id); else activeEmployees.add(id); renderEmployees(); if (viewMode === 'meeting') renderMeetingSeats(); } }; window.clearAllMessages = async () => { if (!currentUser || !currentRoom) { showToast('尚未登入或未選取聊天室。', 'warning'); return; } if (isSending) { showToast('AI 正在回覆,請先按「停止」再清除訊息。', 'warning'); return; } if (!confirm('確定要永久刪除當前聊天室的所有訊息嗎?這項操作無法復原。')) return; if (currentUser.isDemo) { localDemoState.messages[currentRoom] = []; conversationHistory = []; document.getElementById('messages').innerHTML = ''; renderEmptyState(); showToast('目前主題的對話紀錄已清空 (Demo)。', 'success'); return; } try { const q = query(collection(db, 'users', currentUser.uid, 'rooms', currentRoom, 'messages')); const docs = await getDocs(q); const delPromises = docs.docs.map(d => deleteDoc(d.ref)); await Promise.all(delPromises); showToast('目前主題的對話紀錄已清空。', 'success'); } catch (e) { console.error(e); showToast('清除失敗,請稍後再試。', 'error'); } }; function showToast(message, type = 'info', duration = 4200) { const region = document.getElementById('toast-region'); if (!region || !message) return; const toast = document.createElement('div'); toast.className = 'app-toast'; toast.dataset.type = type; toast.setAttribute('role', type === 'error' ? 'alert' : 'status'); const icon = type === 'success' ? '✓' : type === 'error' ? '!' : type === 'warning' ? '⚠' : 'i'; toast.innerHTML = `${icon}${escapeHtml(message)}`; region.appendChild(toast); requestAnimationFrame(() => toast.classList.add('visible')); const remove = () => { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 180); }; setTimeout(remove, duration); } function roomActivity(room) { if (Number.isFinite(room?._activityAt)) return room._activityAt; return room?.updatedAt?.toMillis?.() || room?.createdAt?.toMillis?.() || 0; } function sortRoomsByActivity(items) { return [...items].sort((left, right) => roomActivity(right) - roomActivity(left)); } function roomStorageKey() { return currentUser ? `ai-office-room:${currentUser.uid}` : 'ai-office-room'; } function renderRoomControls() { const select = document.getElementById('room-select'); const brandSub = document.getElementById('brand-sub'); const room = rooms.find(item => item.id === currentRoom); if (select) { select.replaceChildren(); rooms.forEach(item => { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name || '未命名主題'; option.selected = item.id === currentRoom; select.appendChild(option); }); select.disabled = rooms.length === 0; } if (brandSub) { brandSub.textContent = room ? `6 位專家 · ${room.name || '未命名主題'}` : '6 位專家 · 準備討論'; } } window.openRoomEditor = (mode = 'create') => { if (!currentUser) return; if (isSending) { showToast('AI 正在回覆,請先按「停止」再變更討論主題。', 'warning'); return; } const room = rooms.find(item => item.id === currentRoom); if (mode === 'rename' && !room) return; roomEditorMode = mode === 'rename' ? 'rename' : 'create'; const editor = document.getElementById('room-editor'); const label = document.getElementById('room-editor-label'); const input = document.getElementById('room-name-input'); const save = document.getElementById('room-editor-save'); if (!editor || !input) return; if (label) label.textContent = roomEditorMode === 'rename' ? '重新命名主題' : '新主題名稱'; if (save) save.textContent = roomEditorMode === 'rename' ? '儲存' : '建立'; input.value = roomEditorMode === 'rename' ? (room?.name || '') : ''; editor.classList.add('open'); requestAnimationFrame(() => { input.focus(); input.select(); }); }; window.closeRoomEditor = () => { document.getElementById('room-editor')?.classList.remove('open'); const input = document.getElementById('room-name-input'); if (input) input.value = ''; }; window.saveRoomEditor = async event => { event?.preventDefault(); if (isRoomMutationBusy || !currentUser) return; const input = document.getElementById('room-name-input'); const save = document.getElementById('room-editor-save'); const name = String(input?.value || '').replace(/\s+/g, ' ').trim().slice(0, 60); if (!name) { showToast('請輸入討論主題名稱。', 'warning'); input?.focus(); return; } isRoomMutationBusy = true; if (save) save.disabled = true; try { if (currentUser.isDemo) { if (roomEditorMode === 'rename') { const room = rooms.find(item => item.id === currentRoom); if (room) { room.name = name; room._activityAt = Date.now(); } renderRoomControls(); showToast('討論主題已重新命名 (Demo)。', 'success'); } else { const newId = 'demo_room_' + Date.now(); const newRoom = { id: newId, name, _activityAt: Date.now() }; localDemoState.rooms.unshift(newRoom); localDemoState.messages[newId] = []; rooms = sortRoomsByActivity(localDemoState.rooms); renderRoomControls(); closeRoomEditor(); switchRoom(newId, { force: true }); showToast('新討論主題已建立 (Demo)。', 'success'); return; } closeRoomEditor(); return; } if (roomEditorMode === 'rename') { if (!currentRoom) throw new Error('目前沒有可重新命名的主題'); await updateDoc(doc(db, 'users', currentUser.uid, 'rooms', currentRoom), { name, updatedAt: serverTimestamp() }); const room = rooms.find(item => item.id === currentRoom); if (room) { room.name = name; room._activityAt = Date.now(); } renderRoomControls(); showToast('討論主題已重新命名。', 'success'); } else { const docRef = await addDoc(collection(db, 'users', currentUser.uid, 'rooms'), { name, createdAt: serverTimestamp(), updatedAt: serverTimestamp() }); rooms = sortRoomsByActivity([{ id: docRef.id, name, _activityAt: Date.now() }, ...rooms]); renderRoomControls(); closeRoomEditor(); switchRoom(docRef.id, { force: true }); showToast('新討論主題已建立。', 'success'); return; } closeRoomEditor(); } catch (error) { console.error(error); showToast('主題儲存失敗,請稍後再試。', 'error', 6500); } finally { isRoomMutationBusy = false; if (save) save.disabled = false; } }; window.deleteCurrentRoom = async () => { if (!currentUser || !currentRoom || isRoomMutationBusy) return; if (isSending) { showToast('AI 正在回覆,請先按「停止」再刪除討論主題。', 'warning'); return; } if (rooms.length <= 1) { showToast('至少要保留一個討論主題;你可以清除其中的訊息。', 'warning'); return; } const room = rooms.find(item => item.id === currentRoom); if (!confirm(`確定要刪除「${room?.name || '目前主題'}」及其中所有訊息嗎?這項操作無法復原。`)) return; if (currentUser.isDemo) { const roomId = currentRoom; delete localDemoState.messages[roomId]; localDemoState.rooms = localDemoState.rooms.filter(item => item.id !== roomId); rooms = [...localDemoState.rooms]; const nextRoom = rooms[0]; renderRoomControls(); switchRoom(nextRoom.id, { force: true }); showToast('討論主題已刪除 (Demo)。', 'success'); return; } isRoomMutationBusy = true; try { const roomId = currentRoom; const messageSnap = await getDocs(collection(db, 'users', currentUser.uid, 'rooms', roomId, 'messages')); for (let index = 0; index < messageSnap.docs.length; index += 40) { await Promise.all(messageSnap.docs.slice(index, index + 40).map(item => deleteDoc(item.ref))); } await deleteDoc(doc(db, 'users', currentUser.uid, 'rooms', roomId)); rooms = rooms.filter(item => item.id !== roomId); const nextRoom = rooms[0]; renderRoomControls(); switchRoom(nextRoom.id, { force: true }); showToast('討論主題已刪除。', 'success'); } catch (error) { console.error(error); showToast('主題刪除失敗,請稍後再試。', 'error', 6500); } finally { isRoomMutationBusy = false; } }; async function loadRooms() { if (currentUser?.isDemo) { rooms = sortRoomsByActivity(localDemoState.rooms); const initialRoom = rooms[0]; renderRoomControls(); switchRoom(initialRoom.id, { force: true }); return; } const snap = await getDocs(collection(db, 'users', currentUser.uid, 'rooms')); rooms = sortRoomsByActivity(snap.docs.map(item => ({ id: item.id, ...item.data() }))); if (rooms.length === 0) { const docRef = await addDoc(collection(db, 'users', currentUser.uid, 'rooms'), { name: '我的討論', createdAt: serverTimestamp(), updatedAt: serverTimestamp() }); rooms = [{ id: docRef.id, name: '我的討論', _activityAt: Date.now() }]; } const rememberedRoom = localStorage.getItem(roomStorageKey()); const initialRoom = rooms.find(item => item.id === rememberedRoom) || rooms[0]; renderRoomControls(); switchRoom(initialRoom.id, { force: true }); } window.switchRoom = (id, options = {}) => { const room = rooms.find(item => item.id === id); if (!room) { renderRoomControls(); return; } if (isSending && currentRoom && id !== currentRoom && options.force !== true) { renderRoomControls(); showToast('AI 正在回覆,請先按「停止」再切換討論主題。', 'warning'); return; } clearMeetingSpeechPlayback(true); currentRoom = id; roundIndex = 0; localStorage.setItem(roomStorageKey(), id); renderRoomControls(); closeRoomEditor(); if (unsub) { unsub(); unsub = null; } const responseStatus = document.getElementById('response-status'); if (responseStatus) { responseStatus.style.display = 'none'; responseStatus.textContent = ''; } if (currentUser?.isDemo) { document.getElementById('messages').innerHTML = ''; conversationHistory = []; const demoMsgs = localDemoState.messages[id] || []; demoMsgs.forEach(m => { conversationHistory.push(m); renderMessage(m); }); if (demoMsgs.length === 0) renderEmptyState(); if (viewMode === 'meeting') renderMeetingSeats(); scrollToBottom(); return; } const q = query(collection(db, 'users', currentUser.uid, 'rooms', id, 'messages'), orderBy('timestamp', 'asc')); unsub = onSnapshot(q, (snap) => { document.getElementById('messages').innerHTML = ''; conversationHistory = []; let visibleMessages = 0; snap.forEach(d => { const m = { id: d.id, ...d.data() }; if (m.role !== 'user' && TECHNICAL_FAILURE_PATTERN.test(String(m.content || ''))) return; visibleMessages += 1; conversationHistory.push({ id: m.id, role: m.role === 'user' ? 'user' : 'assistant', content: m.content, ai: m.ai, targetAi: m.targetAi, kind: m.kind, isSummary: m.isSummary, mock: m.mock === true, debateTarget: m.debateTarget, namedTarget: m.namedTarget }); renderMessage(m); }); if (visibleMessages === 0) renderEmptyState(); if (viewMode === 'meeting') renderMeetingSeats(); scrollToBottom(); }, (error) => { console.error(error); showToast('訊息同步失敗,請重新整理或切換主題後再試。', 'error', 7000); }); }; function renderEmptyState() { const container = document.getElementById('messages'); container.innerHTML = `
✦

召集你的 AI 圓桌

提出一個問題,讓六位專家從策略、速度、邏輯、效率、多模型與創新角度共同回答。

`; } function renderMessage(m) { const container = document.getElementById('messages'); const isUser = m.role === 'user'; const emp = EMPLOYEES.find(e => e.id === m.ai); const customNames = getCustomNames(); const nickname = emp ? customNames[emp.id] : ''; const displayName = isUser ? '你' : (nickname ? `${nickname} · ${emp?.name || 'AI'}` : (emp?.name || 'AI')); const targetEmp = m.targetAi ? EMPLOYEES.find(e => e.id === m.targetAi) : null; const targetPrefix = (isUser && targetEmp) ? `@${targetEmp.name}` : ''; // 任務 2:重試後仍未點名目標的辯論回覆,保留原文並在訊息上標註(不無聲丟棄)。 const debateTargetEmp = (!isUser && m.debateTarget) ? EMPLOYEES.find(e => e.id === m.debateTarget) : null; const unnamedBadge = (!isUser && m.namedTarget === false) ? `⚠️ 未點名回應${debateTargetEmp ? `「${escapeHtml(debateTargetEmp.name)}」` : '目標'}` : ''; // V1.2 任務 4(c):總結與否只看寫入時存的 kind / isSummary 欄位,不再用文字內容猜測。 const isSummaryMessage = m.kind === 'summary' || m.isSummary === true; const html = `
${isUser?'你':(emp?.initial||'AI')}
${escapeHtml(displayName)} ${isUser?'提問者':(emp?.role||'AI 專家')}${unnamedBadge} ${(!isUser && m.mock === true) ? 'Mock 模擬' : ''}
${targetPrefix}${renderMarkdown(m.content)}
`; container.insertAdjacentHTML('beforeend', html); // MathJax 渲染 if (typeof MathJax !== 'undefined' && MathJax.typesetPromise) { const lastEl = container.lastElementChild; if (lastEl) MathJax.typesetPromise([lastEl]).catch(() => {}); } } function setSending(value) { isSending = value; if (value) cancelRequested = false; const button = document.getElementById('send-btn'); if (button) { button.disabled = false; button.classList.toggle('is-stop', value); button.textContent = value ? '停止 ■' : '送出 ↑'; button.title = value ? '停止仍在等待的 AI;已完成回覆會保留' : '送出訊息'; } } window.handleSendAction = () => { if (isSending) { cancelActiveRequests(); return; } sendMessage(); }; function cancelActiveRequests() { if (!isSending) return; cancelRequested = true; activeRequestControllers.forEach(controller => controller.abort('user_cancelled')); activeRequestControllers.clear(); clearMeetingSpeechPlayback(true); showResponseNotice('已停止等待;先前已完成的 AI 回覆仍會保留。'); const button = document.getElementById('send-btn'); if (button) { button.disabled = true; button.textContent = '正在停止…'; } } function showResponseNotice(message) { const status = document.getElementById('response-status'); if (!status) return; status.style.display = 'block'; status.textContent = message; } function updateResponseStatus(pendingNames, completed, total) { const status = document.getElementById('response-status'); const button = document.getElementById('send-btn'); if (!status) return; if (pendingNames.length > 0) { status.style.display = 'block'; status.textContent = `已完成 ${completed}/${total};等待:${pendingNames.join('、')}`; if (button) button.textContent = `停止 ${completed}/${total} ■`; } else { status.style.display = 'none'; status.textContent = ''; if (button && !isSending) button.textContent = '送出'; } } function supportsFile(emp, mimeType) { if (!emp || !mimeType) return false; return emp.fileSupport === 'all' || (emp.fileSupport === 'image' && mimeType.startsWith('image/')); } function buildApiHistory(history = conversationHistory, options = {}) { const includePeerReplies = options.includePeerReplies === true; const aiId = options.aiId || null; const normalized = history .filter(message => ['user', 'assistant'].includes(message.role) && typeof message.content === 'string') .filter(message => message.role === 'user' || includePeerReplies || (aiId && message.ai === aiId)) .map(message => { if (message.role === 'user') return { role: 'user', content: message.content }; const employee = EMPLOYEES.find(item => item.id === message.ai); const label = employee?.name || message.ai || 'AI'; return { role: 'assistant', content: `[${label}] ${message.content}` }; }); const selected = []; let characterCount = 0; for (let index = normalized.length - 1; index >= 0 && selected.length < MAX_HISTORY_MESSAGES; index--) { const message = normalized[index]; const content = String(message.content || '').slice(0, 12000); if (characterCount + content.length > MAX_HISTORY_CHARACTERS && selected.length > 0) break; selected.push({ role: message.role, content }); characterCount += content.length; } selected.reverse(); while (selected.length > 0 && selected[0].role !== 'user') selected.shift(); return selected; } function createAiRequestError(message, code = 'AI_REQUEST_FAILED', retryable = false, status = 0) { const error = new Error(message); error.code = code; error.retryable = retryable; error.status = status; return error; } // V1.2 任務 5:AUTH_EXPIRED 本身不可重試(Worker 401 回 retryable:false 是正確的), // 但「剛刷新過 token 後的 AUTH_EXPIRED」可重試一次——新 token 還沒被用過,值得再試一次。 // authRefreshed 由呼叫端傳入當次請求是否已刷新過 token;未刷新時仍不可重試,避免無限迴圈。 function isRetryableAiError(error, authRefreshed = false) { if (error?.code === 'AUTH_EXPIRED') return authRefreshed === true; return error?.retryable === true || ['AI_TIMEOUT', 'AI_RATE_LIMIT', 'AI_UNAVAILABLE', 'NETWORK_ERROR'].includes(error?.code); } function friendlyAiError(error, name = 'AI') { const code = error?.code || ''; if (code === 'REQUEST_CANCELLED') return `${name} 已依你的要求停止等待`; if (code === 'DEMO_RESTRICTED') return 'Demo 模式僅提供本機模擬回覆,不連線遠端服務'; if (code === 'AI_TIMEOUT') return `${name} 回應逾時,已停止等待`; if (code === 'AI_RATE_LIMIT') return `${name} 目前使用量過高`; if (code === 'AUTH_EXPIRED') return '登入已失效,請重新登入'; if (code === 'DUPLICATE_REPLY') return `${name} 的內容與其他專家完全相同,已略過重複回覆`; if (code === 'DUPLICATE_ANALYSIS') return `${name} 未能提出足夠不同的觀點`; if (code === 'SAVE_FAILED') return '回覆已產生,但對話紀錄暫時無法保存'; if (code === 'AI_INVALID_RESPONSE') return `${name} 回傳格式異常,已攔截`; return `${name} 暫時無法完成回應`; } function normalizeReplySignature(value) { return String(value || '').toLowerCase().replace(/[\s\p{P}\p{S}]/gu, '').slice(0, 6000); } async function fetchAiJson(url, options, timeoutMs) { const controller = new AbortController(); activeRequestControllers.add(controller); const timer = setTimeout(() => controller.abort('timeout'), timeoutMs); let response; try { response = await fetch(url, { ...options, signal: controller.signal }); } catch (error) { if (controller.signal.aborted || error?.name === 'AbortError') { if (cancelRequested || controller.signal.reason === 'user_cancelled') { throw createAiRequestError('使用者已停止等待', 'REQUEST_CANCELLED', false, 499); } throw createAiRequestError('AI 回應逾時', 'AI_TIMEOUT', true, 504); } throw createAiRequestError('網路連線失敗', 'NETWORK_ERROR', true, 0); } finally { clearTimeout(timer); activeRequestControllers.delete(controller); } let payload; try { payload = await response.json(); } catch { throw createAiRequestError(`Worker 回傳無效內容(HTTP ${response.status})`, 'WORKER_INVALID_RESPONSE', false, response.status); } if (!response.ok || payload?.error) { const retryableStatus = [408, 425, 429, 500, 502, 503, 504].includes(response.status); throw createAiRequestError( payload?.error || `HTTP ${response.status}`, payload?.code || (response.status === 401 ? 'AUTH_EXPIRED' : 'AI_REQUEST_FAILED'), payload?.retryable === true || retryableStatus, response.status ); } return payload; } window.sendMessage = async () => { if (isSending || !currentUser || !currentRoom) return; const input = document.getElementById('msg-input'); const text = input.value.trim(); if (!text && !pendingFileBase64) return; const selected = currentMode === 'target' ? [EMPLOYEES.find(employee => employee.id === targetAI)].filter(Boolean) : EMPLOYEES.filter(employee => activeEmployees.has(employee.id)); if (selected.length === 0) { showToast('請至少選擇一位 AI 專家。', 'warning'); return; } const fileData = pendingFileBase64; const fileMime = pendingFileMime; let responders = selected; if (fileData) { responders = selected.filter(employee => supportsFile(employee, fileMime)); if (responders.length === 0) { showToast('目前選取的 AI 不支援此檔案格式。圖片可交給 Gemini 或 Mistral,其他檔案請使用 Gemini。', 'warning', 6500); return; } if (responders.length < selected.length) { showToast(`檔案只會交給支援此格式的 AI:${responders.map(employee => employee.name).join('、')}`, 'info', 6000); } } const displayText = text || '📎 請分析附加檔案'; const requestContext = { user: currentUser, userId: currentUser.uid, roomId: currentRoom }; const requestMessagesByAi = new Map(responders.map(employee => [ employee.id, [ ...buildApiHistory(conversationHistory, { aiId: employee.id }), { role: 'user', content: displayText } ] ])); const msgTargetAi = currentMode === 'target' ? targetAI : null; updateResponseStatus([], 0, 0); setSending(true); try { await saveMessage('user', displayText, 'user', msgTargetAi, requestContext); if (viewMode === 'meeting') updateMeetingTopic(displayText); input.value = ''; autoResize(input); clearFile(); if (currentMode === 'all') { const pending = new Set(responders.map(employee => employee.name)); const failures = []; const replyRegistry = new Set(); let completed = 0; updateResponseStatus([...pending], completed, responders.length); await Promise.allSettled(responders.map(async employee => { try { await respondOne(displayText, employee, false, fileData, fileMime, requestMessagesByAi.get(employee.id), { requestContext, replyRegistry }); } catch (error) { console.error(`${employee.name} 回應失敗`, error); failures.push({ name: employee.name, message: friendlyAiError(error, employee.name) }); } finally { pending.delete(employee.name); completed++; updateResponseStatus([...pending], completed, responders.length); } })); if (failures.length > 0) { showResponseNotice(`完成 ${responders.length - failures.length}/${responders.length}。${failures.map(item => item.message).join(';')}。`); } } else if (currentMode === 'round') { const employee = responders[roundIndex % responders.length]; roundIndex++; await respondOne(displayText, employee, false, fileData, fileMime, requestMessagesByAi.get(employee.id), { requestContext }); renderEmployees(); } else { await respondOne(displayText, responders[0], false, fileData, fileMime, requestMessagesByAi.get(responders[0].id), { requestContext }); } } catch (error) { console.error(error); showResponseNotice(friendlyAiError(error, currentMode === 'target' ? (responders[0]?.name || 'AI') : 'AI')); } finally { setSending(false); } }; function debateTextSimilarity(left, right) { const clean = value => String(value || '').toLowerCase().replace(/[\s\p{P}\p{S}]/gu, ''); const a = clean(left); const b = clean(right); if (!a || !b) return 0; if (a === b) return 1; const shorter = a.length <= b.length ? a : b; const longer = a.length > b.length ? a : b; if (shorter.length >= 40 && longer.includes(shorter)) return shorter.length / longer.length; const shingles = value => { const result = new Set(); const size = value.length < 30 ? 2 : 3; for (let index = 0; index <= value.length - size; index++) result.add(value.slice(index, index + size)); return result; }; const first = shingles(a); const second = shingles(b); let intersection = 0; first.forEach(item => { if (second.has(item)) intersection++; }); return intersection / Math.max(1, first.size + second.size - intersection); } async function respondOne(text, emp, isSummary = false, fileData = null, fileMime = null, requestMessages = null, responsePolicy = null) { const requestContext = responsePolicy?.requestContext || { user: currentUser, userId: currentUser?.uid, roomId: currentRoom }; const authUser = requestContext.user; if (!emp || !authUser || !requestContext.userId || !requestContext.roomId) { throw createAiRequestError('對話狀態已變更', 'REQUEST_CONTEXT_LOST', false); } // Demo 一律走本機 Mock:不打 Worker、不取 Firebase token、不寫 Firestore。 // (原 DEMO_RESTRICTED 拋錯已移除:Demo 在正式 Worker 下 6 位全失敗,見最終稽查 B1。) if (authUser?.isDemo) { setEmployeeRuntimeStatus(emp.id, 'working', '模擬回應中'); const demoRound = responsePolicy?.demoRound || Number(String(text || '').match(/第\s*(\d+)\s*輪/)?.[1] || 1); const demoKind = isSummary ? 'summary' : (responsePolicy?.demoKind || (demoRound <= 1 ? 'statement' : 'reply')); const demoTopic = extractDemoTopic(text); // V1.2.1 修復 1:總結優先使用明確傳入的總結歷史 summaryPreviousReplies // (startDiscussion 以 debateHistory 成功發言為準);未傳入或為空時,才回退到 avoidTexts。 const summaryPrevious = Array.isArray(responsePolicy?.summaryPreviousReplies) ? responsePolicy.summaryPreviousReplies.filter(item => typeof item === 'string' && item.trim()) : []; const demoPrevious = summaryPrevious.length > 0 ? summaryPrevious : (Array.isArray(responsePolicy?.avoidTexts) ? responsePolicy.avoidTexts : []); // 總結沒有回應對象:不以首則發言作者冒充 target;非總結才沿用舊規則推導。 const demoTarget = demoKind === 'summary' ? (responsePolicy?.demoTargetName || null) : (responsePolicy?.demoTargetName || parseMockReplyAuthor(demoPrevious[0]) || null); let demoReply = generateLocalMockReply( emp.id, demoTopic, demoKind === 'summary' ? 'summary' : demoRound, demoTarget, demoPrevious, demoKind ); if (fileData) demoReply += '(本機模擬無法實際讀取附件,僅就文字議題回應。)'; const demoSignature = normalizeReplySignature(demoReply); if (demoSignature.length >= 30 && responsePolicy?.replyRegistry) responsePolicy.replyRegistry.add(demoSignature); if (responsePolicy?.trackAccepted && responsePolicy?.avoidTexts) responsePolicy.avoidTexts.push(demoReply); recordMockReply(demoReply); if (!responsePolicy?.deferSave) { try { await saveMessage('assistant', demoReply, emp.id, null, requestContext, true); } catch (error) { setEmployeeRuntimeStatus(emp.id, 'error', '保存失敗'); throw error; } if (viewMode === 'meeting' && requestContext.roomId === currentRoom) showSpeechBubble(emp.id, demoReply); } setEmployeeRuntimeStatus(emp.id, 'ready', '已回應(模擬)'); return demoReply; } setEmployeeRuntimeStatus(emp.id, 'working', '回應中'); const messages = requestMessages || [...buildApiHistory(conversationHistory, { aiId: emp.id }), { role: 'user', content: text }]; const noSearch = isSummary; const deadlineAt = Date.now() + AI_REQUEST_DEADLINE_MS; let idToken = await authUser.getIdToken(); let lastError; let acceptedReply = ''; let acceptedMock = false; let needsDistinctRetry = false; let acceptedSignature = ''; // V1.2 任務 5:追蹤「本次 respondOne 是否已刷新過 token」。只允許刷新一次; // 刷新後仍 401 表示登入真的失效,必須走登出回登入畫面,不再重試。 // V1.2 任務 4(c):記錄本次回覆是否為 Worker 受控 Mock(payload.mock === true),存檔時寫入 mock 欄位。 let tokenRefreshedThisAttempt = false; let isMockReply = false; for (let attempt = 1; attempt <= AI_REQUEST_ATTEMPTS; attempt++) { if (cancelRequested) { lastError = createAiRequestError('使用者已停止等待', 'REQUEST_CANCELLED', false, 499); break; } const remainingMs = deadlineAt - Date.now(); if (remainingMs < 2500) { lastError = createAiRequestError('AI 回應逾時', 'AI_TIMEOUT', true, 504); break; } try { const retryMessages = needsDistinctRetry ? [...messages, { role: 'user', content: '上一版與其他專家的內容過度相似,請完全重寫。只提出尚未出現的新觀點、新風險或不同方案,不得沿用相同標題、句型與例子。' }] : messages; const payload = await fetchAiJson(`${WORKER_URL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${idToken}` }, body: JSON.stringify({ ai: emp.id, messages: retryMessages, isSummary, fileData, fileMime, noSearch, debateSessionId: responsePolicy?.debateSessionId, systemPrompt: responsePolicy?.systemPrompt || `你的職責是 ${emp.role}。請從此專業角色提出具體見解,不要討論模型名稱或版本。` }) }, Math.min(AI_ATTEMPT_TIMEOUT_MS, remainingMs)); // V1.2 任務 4(c):Worker 受控 Mock 回 { reply, mock: true };真實回覆回 mock:false(或舊版無此欄位)。 isMockReply = payload.mock === true; const reply = String(payload.reply || '').trim(); if (!reply || TECHNICAL_FAILURE_PATTERN.test(reply)) { throw createAiRequestError(`${emp.name} 回傳了不可顯示內容`, 'AI_INVALID_RESPONSE', true, 502); } // V1.2 任務 6:讀取 Worker 回傳的 mock 旗標(受控 Mock 時為 true,真實回覆應為 false/缺省)。 // 若 Worker 未回傳 mock,視為真實回覆(mock:false),不以內容關鍵字猜測。 acceptedMock = payload.mock === true; if (acceptedMock) recordMockReply(reply); const tooSimilar = (responsePolicy?.avoidTexts || []).some(previous => debateTextSimilarity(reply, previous) >= 0.48); if (tooSimilar) { needsDistinctRetry = true; throw createAiRequestError(`${emp.name} 回答與前一位過度相似`, 'DUPLICATE_ANALYSIS', attempt < AI_REQUEST_ATTEMPTS, 422); } const signature = normalizeReplySignature(reply); if (signature.length >= 30 && responsePolicy?.replyRegistry?.has(signature)) { throw createAiRequestError(`${emp.name} 回覆與其他專家完全相同`, 'DUPLICATE_REPLY', false, 409); } if (signature.length >= 30 && responsePolicy?.replyRegistry) { responsePolicy.replyRegistry.add(signature); acceptedSignature = signature; } if (responsePolicy?.trackAccepted && responsePolicy?.avoidTexts) { responsePolicy.avoidTexts.push(reply); } acceptedReply = reply; break; } catch (error) { lastError = error; // [Staging B-17] AUTH_EXPIRED 刷新後也不重送模型呼叫(與生產 V1.2.1 的差異): // 生產會在刷新 token 後以新 token 重試一次;Staging 即使刷新成功也不重發同一模型呼叫, // 刷新僅為讓下方 !acceptedReply 分支能依 tokenRefreshedThisAttempt 判斷登入失效並提示重登。 // 刷新本身失敗也視為「已刷新」,不重送、不等待。 if (error?.code === 'AUTH_EXPIRED' && !tokenRefreshedThisAttempt) { try { idToken = await authUser.getIdToken(true); } catch (refreshError) { console.error('Token 刷新失敗', refreshError); } tokenRefreshedThisAttempt = true; } // [Staging B-17] 迴圈內失敗即停止:AI_REQUEST_ATTEMPTS = 1,canRetry 恆為 false, // fetch 失敗、非 2xx、逾時、去重誤判皆直接 break,隨後由 !acceptedReply 分支拋錯,不再嘗試第二次。 const canRetry = false && attempt < AI_REQUEST_ATTEMPTS && isRetryableAiError(error, tokenRefreshedThisAttempt) && deadlineAt - Date.now() > 3500; if (!canRetry) break; await new Promise(resolve => setTimeout(resolve, Math.min(900 * attempt, 1200))); } } if (!acceptedReply) { const wasCancelled = lastError?.code === 'REQUEST_CANCELLED'; // V1.2 任務 5:第二次 401(token 已刷新過仍被拒)才認定登入失效:提示並登出, // 由 onAuthStateChanged 顯示登入畫面。Demo 身分沒有真實 Firebase 會話,不登出。 // [Staging B-17] 與生產的差異:生產第一次 401 走重試、只有第二次 401 到這裡; // Staging 不重送,第一次 401(已刷新 token)即到這裡,提示重登並由呼叫端停止本場。 if (lastError?.code === 'AUTH_EXPIRED' && tokenRefreshedThisAttempt && !authUser?.isDemo) { showToast('登入已失效,請重新登入。', 'error', 7000); signOut(auth).catch(signOutError => console.error('登出失敗', signOutError)); } setEmployeeRuntimeStatus(emp.id, wasCancelled ? 'idle' : 'error', wasCancelled ? '已停止' : '暫時失敗'); throw lastError || createAiRequestError(`${emp.name} 暫時無法完成回應`); } if (!responsePolicy?.deferSave) { try { // V1.2 任務 4(c):總結回覆寫入 kind:'summary' + isSummary:true;所有回覆都寫入 mock 欄位。 const saveMeta = { mock: (typeof isMockReply === 'boolean' ? isMockReply : acceptedMock) }; if (isSummary) { saveMeta.kind = 'summary'; saveMeta.isSummary = true; } await saveMessage('assistant', acceptedReply, emp.id, null, requestContext, saveMeta); } catch (error) { if (acceptedSignature && responsePolicy?.replyRegistry) responsePolicy.replyRegistry.delete(acceptedSignature); setEmployeeRuntimeStatus(emp.id, 'error', '保存失敗'); throw error; } if (viewMode === 'meeting' && requestContext.roomId === currentRoom) showSpeechBubble(emp.id, acceptedReply, { isSummary }); } setEmployeeRuntimeStatus(emp.id, 'ready', '已回應'); return acceptedReply; } window.triggerFileSelect = () => document.getElementById('file-input').click(); window.handleFileSelect = (e) => { const file = e.target.files[0]; if (!file) return; if (file.size > 5 * 1024 * 1024) { showToast('檔案大小不能超過 5MB。', 'warning'); e.target.value = ''; return; } const reader = new FileReader(); reader.onload = (event) => { pendingFileBase64 = event.target.result.split(',')[1]; pendingFileMime = file.type || 'application/octet-stream'; document.getElementById('file-name').textContent = `📎 ${file.name}`; document.getElementById('file-preview').style.display = 'flex'; }; reader.readAsDataURL(file); }; window.copyMsg = async id => { const msg = conversationHistory.find(item => item.id === id); if (!msg) return; try { await navigator.clipboard.writeText(msg.content); showToast('訊息內容已複製。', 'success'); } catch (error) { console.error(error); showToast('無法存取剪貼簿,請手動選取內容。', 'error'); } }; window.deleteMsg = async (id) => { if (!confirm('確定要刪除這條訊息嗎?')) return; try { await deleteDoc(doc(db, 'users', currentUser.uid, 'rooms', currentRoom, 'messages', id)); } catch (e) { console.error(e); showToast('訊息刪除失敗,請稍後再試。', 'error'); } }; window.clearFile = () => { pendingFileBase64 = null; pendingFileMime = null; document.getElementById('file-input').value = ''; document.getElementById('file-preview').style.display = 'none'; }; let recognition = null; window.toggleVoiceInput = () => { if (!('webkitSpeechRecognition' in window)) { showToast('這個瀏覽器不支援語音輸入。', 'warning'); return; } if (!recognition) { recognition = new webkitSpeechRecognition(); recognition.continuous = false; recognition.interimResults = false; recognition.lang = 'zh-TW'; recognition.onstart = () => { isRecording = true; document.getElementById('voice-btn').classList.add('recording'); }; recognition.onend = () => { isRecording = false; document.getElementById('voice-btn').classList.remove('recording'); }; recognition.onresult = (e) => { const text = e.results[0][0].transcript; const input = document.getElementById('msg-input'); input.value += text; autoResize(input); }; } isRecording ? recognition.stop() : recognition.start(); }; // V1.2 任務 4(c)+6:saveMessage 第 6 參數統一為 meta 物件 { kind, isSummary, mock, debateTarget, namedTarget };為相容舊呼叫,傳入布林值時視為 { mock: 布林值 }。 async function saveMessage(role, content, ai, targetAi = null, context = null, meta = null) { const metaObj = (typeof meta === 'boolean') ? { mock: meta } : (meta || {}); const userId = context?.userId || currentUser?.uid; const roomId = context?.roomId || currentRoom; if (!userId || !roomId) throw createAiRequestError('對話狀態已變更', 'REQUEST_CONTEXT_LOST', false); // V1.2 任務 6:mock 布林值一律儲存,真實回覆明確寫 mock:false,不以缺省代表真實。 const mockFlag = metaObj.mock === true; const messageData = { role, content, ai, mock: mockFlag, timestamp: serverTimestamp() }; if (targetAi) messageData.targetAi = targetAi; // 任務 2:辯論指定回應目標與點名檢查結果,隨訊息一併保存(不覆寫 targetAi)。 if (metaObj?.debateTarget) messageData.debateTarget = metaObj.debateTarget; if (typeof metaObj?.namedTarget === 'boolean') messageData.namedTarget = metaObj.namedTarget; if (metaObj?.kind) messageData.kind = metaObj.kind; if (typeof metaObj?.isSummary === 'boolean') messageData.isSummary = metaObj.isSummary; if (typeof metaObj?.mock === 'boolean') messageData.mock = metaObj.mock; if (currentUser?.isDemo) { const demoId = 'demo_msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); const demoMsg = { id: demoId, role, content, ai, mock: mockFlag, timestamp: { toMillis: () => Date.now() } }; if (targetAi) demoMsg.targetAi = targetAi; if (metaObj?.debateTarget) demoMsg.debateTarget = metaObj.debateTarget; if (typeof metaObj?.namedTarget === 'boolean') demoMsg.namedTarget = metaObj.namedTarget; if (metaObj?.kind) demoMsg.kind = metaObj.kind; if (typeof metaObj?.isSummary === 'boolean') demoMsg.isSummary = metaObj.isSummary; if (typeof metaObj?.mock === 'boolean') demoMsg.mock = metaObj.mock; if (!localDemoState.messages[roomId]) localDemoState.messages[roomId] = []; localDemoState.messages[roomId].push(demoMsg); conversationHistory.push(demoMsg); if (roomId === currentRoom) { renderMessage(demoMsg); scrollToBottom(); } return { id: demoId }; } const messageRef = doc(collection(db, 'users', userId, 'rooms', roomId, 'messages')); let lastError; for (let attempt = 1; attempt <= 2; attempt++) { try { await setDoc(messageRef, messageData); if (role === 'user') { const room = rooms.find(item => item.id === roomId); if (room) { room._activityAt = Date.now(); rooms = sortRoomsByActivity(rooms); renderRoomControls(); } updateDoc(doc(db, 'users', userId, 'rooms', roomId), { updatedAt: serverTimestamp() }).catch(error => console.warn('主題活動時間更新失敗', error)); } return messageRef; } catch (error) { lastError = error; if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 350)); } } console.error('訊息保存失敗', lastError); throw createAiRequestError('對話紀錄暫時無法保存', 'SAVE_FAILED', true); } function renderMarkdown(value) { if (!value) return ''; const protectedBlocks = []; const tokenPrefix = `SAFE_${crypto.randomUUID?.() || `${Date.now()}_${Math.random()}`}_`; let text = String(value); text = text.replace(/```(?:[\w-]+)?\n?([\s\S]*?)```/g, (match, code) => { const token = `${tokenPrefix}${protectedBlocks.length}__`; protectedBlocks.push({ token, html: `
${escapeHtml(code.trim())}
` }); return token; }); text = text.replace(/`([^`\n]+)`/g, (match, code) => { const token = `${tokenPrefix}${protectedBlocks.length}__`; protectedBlocks.push({ token, html: `${escapeHtml(code)}` }); return token; }); text = escapeHtml(text); const lines = text.split('\n'); const renderedLines = []; for (let index = 0; index < lines.length; index++) { const separator = lines[index + 1]?.trim() || ''; const isTable = lines[index].includes('|') && /^\|?\s*:?-{3,}/.test(separator); if (!isTable) { renderedLines.push(lines[index]); continue; } const parseCells = line => line.trim().replace(/^\||\|$/g, '').split('|').map(cell => cell.trim()); const headers = parseCells(lines[index]); const rows = []; index += 2; while (index < lines.length && lines[index].includes('|')) { rows.push(parseCells(lines[index])); index++; } index--; renderedLines.push( '
' + headers.map(cell => ``).join('') + '' + rows.map(row => '' + row.map(cell => ``).join('') + '').join('') + '
${cell}
${cell}
' ); } text = renderedLines.join('\n'); text = text.replace(/\*\*(.+?)\*\*/g, '$1'); text = text.replace(/\*([^*\n]+)\*/g, '$1'); text = text.replace(/\n/g, '
'); protectedBlocks.forEach(block => { text = text.split(block.token).join(block.html); }); return text; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = String(text ?? ''); return div.innerHTML; } window.autoResize = (el) => { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }; window.handleKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }; window.scrollToBottom = () => { const s = document.getElementById('chat-scroll'); s.scrollTop = s.scrollHeight; }; const MODE_CONTEXTS = { all: { icon: '◉', title: '全員獨立回答', description: '每位 AI 只參考你的訊息與自己的歷史,不會先看其他 AI 的答案。', placeholder: '讓六位專家各自獨立回答…' }, round: { icon: '↻', title: '依序輪流回答', description: '每次只由下一位啟用中的 AI 回答,並延續該 AI 自己與你的對話。', placeholder: '交給下一位專家回答…' }, target: { icon: '◎', title: '指定專家回答', description: '只有你指定的 AI 回答,且不會讀取其他 AI 的回覆。', placeholder: '向指定的 AI 專家提問…' } }; function updateModeContext() { const context = MODE_CONTEXTS[currentMode] || MODE_CONTEXTS.all; const root = document.getElementById('mode-context'); const icon = document.getElementById('mode-context-icon'); const title = document.getElementById('mode-context-title'); const description = document.getElementById('mode-context-description'); const input = document.getElementById('msg-input'); const meetingMode = document.querySelector('.meeting-core-mode'); if (root) root.dataset.contextMode = currentMode; if (icon) icon.textContent = context.icon; if (title) title.textContent = context.title; if (description) description.textContent = context.description; if (input) input.placeholder = context.placeholder; if (meetingMode) meetingMode.textContent = currentMode === 'all' ? '六種觀點 · 獨立分析 · 依序發言' : currentMode === 'round' ? '單一觀點 · 輪流接棒' : '指定觀點 · 專注回應'; } window.useStarterPrompt = (promptText) => { const input = document.getElementById('msg-input'); if (!input) return; input.value = String(promptText || ''); autoResize(input); input.focus(); input.setSelectionRange(input.value.length, input.value.length); }; window.setMode = (m) => { if (!MODE_CONTEXTS[m]) return; currentMode = m; document.querySelectorAll('[data-mode]').forEach(b => b.classList.toggle('active', b.getAttribute('data-mode')===m)); renderEmployees(); updateModeContext(); }; window.renameEmployee = (event, id) => { event?.stopPropagation(); const employee = EMPLOYEES.find(item => item.id === id); if (!employee) return; editingEmployeeId = id; const input = document.getElementById('employee-name-input'); const description = document.getElementById('employee-name-description'); const customNames = getCustomNames(); if (description) description.textContent = `替 ${employee.name} 設定方便辨識的名稱;留空會恢復預設名稱。`; if (input) input.value = customNames[id] || ''; document.getElementById('employee-name-modal')?.classList.add('open'); requestAnimationFrame(() => { input?.focus(); input?.select(); }); }; window.closeEmployeeNameModal = () => { editingEmployeeId = null; document.getElementById('employee-name-modal')?.classList.remove('open'); }; window.saveEmployeeName = event => { event?.preventDefault(); if (!editingEmployeeId) return; const input = document.getElementById('employee-name-input'); const name = String(input?.value || '').replace(/\s+/g, ' ').trim().slice(0, 40); const customNames = getCustomNames(); if (name) customNames[editingEmployeeId] = name; else delete customNames[editingEmployeeId]; localStorage.setItem('customNames', JSON.stringify(customNames)); closeEmployeeNameModal(); renderEmployees(); if (viewMode === 'meeting') renderMeetingSeats(); showToast(name ? '專家暱稱已更新。' : '已恢復專家預設名稱。', 'success'); }; window.copyAllMinutes = async () => { if (conversationHistory.length === 0) { showToast('目前沒有可複製的對話紀錄。', 'warning'); return; } const text = conversationHistory.map(message => { const customNames = getCustomNames(); const employee = EMPLOYEES.find(item => item.id === message.ai); const nickname = customNames[message.ai]; const displayName = nickname ? `${nickname} (${employee?.name})` : (employee?.name || 'AI'); const name = message.role === 'user' ? 'YOU' : displayName; return `[${message.mock === true ? 'Mock ' : ''}${name}] ${message.content}`; }).join('\n\n'); try { await navigator.clipboard.writeText(text); showToast('目前主題的完整紀錄已複製。', 'success'); } catch (error) { console.error(error); showToast('無法存取剪貼簿,請手動選取內容。', 'error'); } }; window.openDiscussModal = () => { const active = EMPLOYEES.filter(employee => activeEmployees.has(employee.id)); if (active.length === 0) { showToast('請至少選擇一位 AI 專家。', 'warning'); return; } const sel = document.getElementById('summarizer-select'); const customNames = getCustomNames(); sel.innerHTML = active.map(employee => { const name = customNames[employee.id] || employee.name; return ``; }).join(''); const topicInput = document.getElementById('discussion-topic'); if (topicInput && !topicInput.value.trim()) { const latestTopic = [...conversationHistory].reverse().find(message => message.role === 'user'); if (latestTopic) topicInput.value = String(latestTopic.content || '').slice(0, 1000); } const modeSelect = document.getElementById('discussion-mode-select'); if (modeSelect) modeSelect.value = selectedDiscussionMode; document.getElementById('discuss-modal').classList.add('open'); requestAnimationFrame(() => topicInput?.focus()); }; window.closeDiscussModal = () => document.getElementById('discuss-modal').classList.remove('open'); window.setRounds = (n) => { selectedRounds = n; document.querySelectorAll('.round-btn').forEach(b => b.classList.toggle('active', b.textContent.includes(n))); }; window.setDiscussionMode = (mode) => { selectedDiscussionMode = mode === 'sequential' ? 'sequential' : 'parallel'; const sel = document.getElementById('discussion-mode-select'); if (sel) sel.value = selectedDiscussionMode; }; // ===== 辯論指定回應對象(任務 2)===== // 固定輪替目標:target = active[(i + 1) % active.length],與發言順序無關, // 平行與循序模式共用同一規則,結果可預期、可重現。 function getDebateTargetExcerpt(history, target) { if (!target) return ''; const prefix = `[${target.name}] `; for (let index = history.length - 1; index >= 0; index--) { const entry = history[index]; if (entry?.role === 'assistant' && String(entry.content || '').startsWith(prefix)) { return String(entry.content).slice(prefix.length).replace(/\s+/g, ' ').trim().slice(0, 80); } } return ''; } // 回覆是否點名目標:接受本名、暱稱(若有自訂)或簡稱(例如「CF AI」接受「CF」)。 function debateReplyNamesTarget(reply, target) { if (!target) return true; const candidates = [target.name]; const nickname = (typeof getCustomNames === 'function' ? getCustomNames()[target.id] : '') || ''; if (nickname) candidates.push(nickname); if (target.name.includes(' ')) candidates.push(target.name.split(' ')[0]); const text = String(reply || ''); return candidates.some(name => name && text.includes(name)); } window.startDiscussion = async () => { if (isSending) return; // [Staging B-17] Staging 驗收流程只取 Gemini、Groq 兩席:本場 active 過濾為僅 gemini 與 groq, // 其他席位不在驗收流程中被呼叫;席位定義本身(EMPLOYEES 六席)不刪除,僅過濾本場 active。 const active = EMPLOYEES.filter(employee => activeEmployees.has(employee.id) && (employee.id === 'gemini' || employee.id === 'groq')); const topicInput = document.getElementById('discussion-topic'); const topic = String(topicInput?.value || '').trim(); if (active.length === 0 || !topic) { showToast(active.length === 0 ? '請至少選擇一位 AI 專家。' : '請先輸入一個討論主題。', 'warning'); if (active.length > 0) topicInput?.focus(); return; } const summarizer = active.find(employee => employee.id === document.getElementById('summarizer-select').value) || active[0]; // 任務 3:討論模式,預設平行;循序時輪內依序呼叫,後者看得到前者。 const discussionMode = document.getElementById('discussion-mode-select')?.value === 'sequential' ? 'sequential' : selectedDiscussionMode; const requestContext = { user: currentUser, userId: currentUser?.uid, roomId: currentRoom }; if (!requestContext.userId || !requestContext.roomId) { showResponseNotice('聊天室尚未準備完成,請稍後再試。'); return; } closeDiscussModal(); setSending(true); const failures = []; try { const topicAlreadyInHistory = conversationHistory.some(message => message.role === 'user' && String(message.content || '').trim() === topic); const debateSessionId = 'deb_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); const debateHistory = [{ role: 'user', content: `本次辯論主題:${topic}`, mock: false }]; const previousReplies = []; const replyRegistry = new Set(); const MAX_CALLS_PER_DEBATE = 24; // V1.2.1 任務 3:與後端口徑對齊,預留 2 次給總結(最多 2 次嘗試);一般發言最多 22 次。 const SUMMARY_RESERVED_CALLS = 2; let debateCallCount = 0; const debateLenses = { gemini: '策略與制度:釐清根因、利害關係人與長期取捨', groq: '反方與風險:挑戰假設,指出盲點、代價與失敗條件', mistral: '邏輯與證據:檢查因果、資料缺口與方案可驗證性', cloudflare: '效率與落地:評估資源、成本、執行順序與短期成效', openrouter: '多元方案:提出替代路徑、情境比較與折衷選項', gemma: '創新實驗:提出小規模、低風險且可衡量的新做法' }; if (!topicAlreadyInHistory) { await saveMessage('user', topic, 'user', null, requestContext); } if (viewMode === 'meeting') updateMeetingTopic(topic); // [Staging V1.2.2 逐字稿修正] 把 debateHistory 的成功發言整理成 user 訊息,供模型閱讀。 // 背景:舊寫法把 debateHistory(assistant 角色、`[姓名] 回覆`)原樣放進 requestMessages, // Worker 的 callGemini 會把 assistant 轉成 model 角色、callGroq 原樣轉發,結果對手的發言 // 在供應商眼中成了「自己(model/assistant)說過的話」,是錯誤的模型呼叫。 // 新寫法:先前發言一律整理成一份標記作者與輪次的逐字稿,以 user 訊息(外部討論紀錄) // 供模型閱讀;供應商收到的請求裡不再有任何一則 assistant/model 訊息含有他人發言。 // - debateHistory 本身的儲存格式不動(UI、Firestore、匯出仍用 `[姓名] 回覆` 的 assistant 形式)。 // - 第 1 輪無先前發言時不產生逐字稿訊息(只回傳主題 user 訊息)。 // - 輪次按「每輪 speakersPerRound 席」的順序位置推算;Staging B-17 任一失敗即停場, // 故任何一輪被取用時該輪席位皆已齊全,位置推算不會錯位。 // - 純函式,不依賴閉包變數,測試可直接抽出驗證;單聊仍走 buildApiHistory,不經此函式。 function buildDebateTranscriptMessages(history, speakersPerRound) { const entries = Array.isArray(history) ? history : []; const topicMessages = entries .filter(message => message?.role === 'user') .map(message => ({ role: 'user', content: String(message.content || '') })); const spoken = entries.filter(message => message?.role === 'assistant'); if (spoken.length === 0) return topicMessages; const perRound = Number.isInteger(speakersPerRound) && speakersPerRound > 0 ? speakersPerRound : spoken.length; const lines = [ '【會議逐字稿:外部討論紀錄】', '以下為本場辯論至今的完整發言紀錄,依發言順序排列。這些發言是其他專家已發表的內容,僅供你閱讀與回應時參考;它們不是你自己的發言,請勿將其中任何一段當成你說過的話,也不得直接複述、摘要或改寫其中內容。' ]; spoken.forEach((message, index) => { const stored = String(message.content || ''); const match = stored.match(/^\[([^\]]+)\]\s*([\s\S]*)$/); const author = match ? match[1] : '專家'; const text = match ? match[2] : stored; const round = Math.floor(index / perRound) + 1; lines.push('---', `[第 ${round} 輪|${author}]`, text); }); lines.push('---', '(逐字稿結束)'); return [...topicMessages, { role: 'user', content: lines.join('\n') }]; } // 單一發言回合:組 prompt(含指定回應對象與引用)、呼叫、點名檢查與一次重試。 // historyForThisTurn 是該發言者看得到的上下文:平行模式為輪首快照 roundHistory; // 循序模式為呼叫當下的 debateHistory 副本,因此同輪前面的人已在其中。 // [Staging V1.2.2 逐字稿修正] 傳給供應商的 requestMessages 經 buildDebateTranscriptMessages // 轉換:先前發言只出現在逐字稿 user 訊息內,不再以 assistant 角色傳出。 const runDebateTurn = async (employee, speakerIndex, historyForThisTurn, round) => { const lens = debateLenses[employee.id] || `以 ${employee.role} 的專業角度分析`; // 任務 2:第 2 輪起固定分配回應目標;只有一位專家時不指定(目標會是自己)。 const target = (round >= 2 && active.length >= 2) ? active[(speakerIndex + 1) % active.length] : null; const targetExcerpt = target ? getDebateTargetExcerpt(historyForThisTurn, target) : ''; const roundTask = round === 1 ? '請獨立立論,不得摘要、重述或改寫其他專家的內容。' : (target ? `請點名回應「${target.name}」的具體主張${targetExcerpt ? `:「${targetExcerpt}」` : ''},開頭第一句就寫明你要回應的是「${target.name}」,說明你要反駁、補強或改採其他方案之處。` : '請點名回應一位前述專家的具體主張,說明你要反駁、補強或改採其他方案之處。'); const instruction = `討論主題:「${topic}」。第 ${round} 輪。你的固定觀察角度是「${lens}」。${roundTask}請用「立場、獨有觀點、具體做法」三段作答,每段一至兩句,總長約 120 至 220 字。不可只說同意,不可沿用其他人的標題、句型或例子;不確定的事實要明確說明,不得虛構數據。`; const debateSystemPrompt = `你是多人辯論中的「${employee.role}」,固定負責:${lens}。你的價值在於提供與其他人不同、可檢驗且能落地的觀點。禁止複述、摘要、套用或輕微改寫其他專家的答案;若已有相同建議,必須改提不同風險、替代方案或驗證方式。使用台灣繁體中文,直接輸出給使用者看的內容。`; const callDebateTurn = (prompt, avoidList) => respondOne( prompt, employee, false, null, null, [...buildDebateTranscriptMessages(historyForThisTurn, active.length), { role: 'user', content: prompt }], { requestContext, avoidTexts: avoidList, replyRegistry, trackAccepted: true, deferSave: true, demoRound: round, demoKind: round === 1 ? 'statement' : 'reply', demoTargetName: target?.name || null, systemPrompt: debateSystemPrompt, debateSessionId } ); if (++debateCallCount > MAX_CALLS_PER_DEBATE - SUMMARY_RESERVED_CALLS) { failures.push({ name: employee.name, round, message: '已達本場會議呼叫次數預算上限' }); return { employee, reply: '', target, namedTarget: null }; } try { const reply = await callDebateTurn(instruction, previousReplies); if (!target) return { employee, reply, target: null, namedTarget: null }; if (debateReplyNamesTarget(reply, target)) return { employee, reply, target, namedTarget: true }; // [Staging B-17] 第 2 輪未點名目標:關閉點名重試分支(生產會同一目標重寫一次), // 保留實際回覆、不重新生成,記 namedTarget:false 並寫入 failures(沿用既有 failures 結構)。 // 首版回覆已由 respondOne(trackAccepted)推入 previousReplies,此處不再移除、不重送。 failures.push({ name: employee.name, round, message: `${employee.name} 第 ${round} 輪回覆未點名「${target.name}」,已保留原回覆(Staging B-17:不重寫)` }); if (!previousReplies.includes(reply)) previousReplies.push(reply); return { employee, reply, target, namedTarget: false }; } catch (error) { console.error(`辯論:${employee.name} 回應失敗`, error); failures.push({ name: employee.name, round, message: friendlyAiError(error, employee.name) }); return { employee, reply: '', target, namedTarget: null }; } }; // 保存一則辯論回覆:寫入對話紀錄(含指定目標與點名標記)、推入 debateHistory。 // 平行與循序共用,行為一致;保存失敗時從去重狀態移除該回覆。 const saveDebateReply = async (result, round) => { if (!result.reply) return; try { await saveMessage('assistant', result.reply, result.employee.id, null, requestContext, { debateTarget: result.target ? result.target.id : null, namedTarget: result.namedTarget, mock: isMockReplyText(result.reply) }); if (viewMode === 'meeting' && requestContext.roomId === currentRoom) showSpeechBubble(result.employee.id, result.reply); debateHistory.push({ role: 'assistant', content: `[${result.employee.name}] ${result.reply}`, mock: isMockReplyText(result.reply) }); } catch (error) { const signature = normalizeReplySignature(result.reply); if (signature) replyRegistry.delete(signature); const replyIndex = previousReplies.indexOf(result.reply); if (replyIndex >= 0) previousReplies.splice(replyIndex, 1); setEmployeeRuntimeStatus(result.employee.id, 'error', '保存失敗'); failures.push({ name: result.employee.name, round, message: friendlyAiError(error, result.employee.name) }); } }; // [Staging B-17] 任一呼叫失敗即停止本場:runDebateTurn 內部已把 respondOne 失敗記入 failures // 並回傳空回覆(reply:'');此旗標讓輪次迴圈在該輪結束後立即 break,不進下一輪、不進總結, // 已完成的發言與錯誤紀錄(failures、debateHistory)保留。平行模式同輪已並行發出的呼叫無法收回, // 但不擴大到下一輪或總結。 let debateAborted = false; for (let round = 1; round <= selectedRounds; round++) { if (cancelRequested) throw createAiRequestError('使用者已停止等待', 'REQUEST_CANCELLED', false, 499); const modeLabel = discussionMode === 'sequential' ? '循序' : '平行'; showResponseNotice(`辯論第 ${round}/${selectedRounds} 輪進行中(${modeLabel}):${active.map(employee => employee.name).join('、')}`); if (discussionMode === 'sequential') { // 任務 3:循序模式。輪內 for...of await 依序呼叫,每位回覆後立即 // 經 saveDebateReply 推入 debateHistory,下一位看得到同輪前面的發言。 // 預算(debateCallCount / MAX_CALLS_PER_DEBATE)、總結、Mock 標記 // 都走同一條 respondOne / saveMessage 路徑,與平行模式相同。 for (let index = 0; index < active.length; index++) { if (cancelRequested) throw createAiRequestError('使用者已停止等待', 'REQUEST_CANCELLED', false, 499); const result = await runDebateTurn(active[index], index, [...debateHistory], round); // [Staging B-17] 循序模式:本席失敗(空回覆)即停止,不再呼叫下一位、不進下一輪、不進總結。 if (!result.reply) { debateAborted = true; break; } await saveDebateReply(result, round); } } else { // 平行模式(預設):維持既有行為,輪首快照一次,六位共用同一快照並行呼叫。 const roundHistory = [...debateHistory]; const roundResults = await Promise.all(active.map((employee, index) => runDebateTurn(employee, index, roundHistory, round))); for (const result of roundResults) { await saveDebateReply(result, round); } // [Staging B-17] 平行模式:本輪任一席失敗即停止,不進下一輪、不進總結;本輪已完成的發言已保存。 if (roundResults.some(result => !result.reply)) debateAborted = true; } if (debateAborted) break; } if (debateAborted) { // [Staging B-17] 任一呼叫失敗即停止本場:跳過總結,已完成的發言與 failures 紀錄保留, // 由下方結尾通知統一說明未完成步驟。 } else if (debateHistory.length === 1) { throw createAiRequestError('所有專家本次皆未完成回應', 'AI_UNAVAILABLE', false); } if (!debateAborted) { showResponseNotice('各方發言完成,正在整理共識、分歧與下一步。'); const summaryInstruction = `請針對討論主題「${topic}」整合以上成功完成的專家觀點,列出共識、分歧與可執行的下一步;不得把未回應的專家寫成已發言。`; // V1.2.1 任務 3:總結納入計數(第 23、24 次額度,最多 2 次嘗試);每次嘗試各計 1 次, // 不再只在迴圈前預先計 1 次——否則第 2 次總結嘗試前端未計數、後端卻計數,會在 22+2=24 時被後端 429 拒絕。 // 一般發言上限為 24-2=22 次,故總結前 debateCallCount ≤ 22,第 1 次計為 23、第 2 次計為 24,皆不超限。 // 超額時不送出請求,改記一筆失敗(由迴圈後的 !summarySucceeded 分支統一記入),後端 429 仍為最終防線。 // V1.2 任務 4(b):總結失敗換下一位專家再試一次(僅一位 fallback)。 // V1.2.1 修復 1:總結歷史以 debateHistory 成功發言為準——只取 role='assistant' 的 // content(排除主題 user 訊息與任何系統訊息),順序保留;保存失敗的發言本就不在 // debateHistory 內,不會被列入。真正零發言時為空陣列,Demo 總結才顯示「尚無其他模擬發言」。 // 注意:刻意不把這份歷史同時傳為 avoidTexts,以免真實模式的去重檢查把「合理整合 // 前人觀點的總結」誤判為與前人過度相似(DUPLICATE_ANALYSIS)而拒絕。 const summaryPreviousReplies = debateHistory .filter(message => message?.role === 'assistant') .map(message => String(message.content || '').trim()) .filter(Boolean); // [Staging B-17] 總結候選人只取首選一位(生產 V1.2 任務 4(b) 會取前 2 位、首選失敗換下一位再試一次): // Staging 首選失敗即記失敗並停止,不嘗試第二位。SUMMARY_RESERVED_CALLS 仍保留 2 次額度(不改預算常數), // 實際只用其中 1 次;下方失敗系統訊息列出的「已嘗試」因此只含首選一人。 const summaryCandidates = [summarizer, ...active.filter(employee => employee.id !== summarizer.id)].slice(0, 1); let summarySucceeded = false; let summaryLastError = null; let summaryBudgetExceeded = false; for (const candidate of summaryCandidates) { if (++debateCallCount > MAX_CALLS_PER_DEBATE) { summaryBudgetExceeded = true; summaryLastError = createAiRequestError('已達本場會議呼叫次數預算上限,總結未執行', 'DEBATE_BUDGET_EXCEEDED', false); break; } try { await respondOne( summaryInstruction, candidate, true, null, null, [...buildDebateTranscriptMessages(debateHistory, active.length), { role: 'user', content: summaryInstruction }], { requestContext, debateSessionId, summaryPreviousReplies, demoKind: 'summary', demoRound: 'summary', demoTargetName: null } ); summarySucceeded = true; break; } catch (error) { summaryLastError = error; console.error(`總結:${candidate.name} 回應失敗`, error); } } if (!summarySucceeded) { failures.push({ name: summarizer.name, round: '總結', message: friendlyAiError(summaryLastError, summarizer.name) }); try { await saveMessage( 'assistant', `⚠️ 本次會議總結未能完成(已嘗試:${summaryCandidates.map(employee => employee.name).join('、')};原因:${friendlyAiError(summaryLastError, summarizer.name)})。各輪專家發言已完整保留在對話紀錄中,可重新發起討論產生新的總結,或直接查看上方內容。`, null, null, requestContext, { kind: 'system', isSummary: false, mock: false } ); } catch (saveError) { console.error('總結失敗的系統訊息保存失敗', saveError); } } } // [Staging B-17] if (!debateAborted):失敗停場時跳過整個總結區塊。 if (topicInput) topicInput.value = ''; if (failures.length > 0) { showResponseNotice(`辯論已完成,但有 ${failures.length} 個步驟未完成:${failures.map(item => item.message).join(';')}。`); } else { updateResponseStatus([], 0, 0); } } catch (error) { console.error(error); showResponseNotice(`辯論未能完整完成:${friendlyAiError(error)}。已完成的內容仍保留。`); } finally { setSending(false); } }; let meetingSpeechQueue = []; let activeMeetingSpeech = null; let meetingSpeechTimer = null; function updateMeetingSpeechState(employee = null) { const queueState = document.getElementById('meeting-queue-state'); const turnState = document.getElementById('meeting-turn-state'); if (queueState) queueState.textContent = meetingSpeechQueue.length > 0 ? `尚有 ${meetingSpeechQueue.length} 位` : (employee ? '本輪發言' : '等待發言'); if (turnState) turnState.textContent = employee ? `${employee.name} 發言` : '等待開會'; } function clearMeetingSpeechPlayback(clearQueue = false) { if (meetingSpeechTimer) clearTimeout(meetingSpeechTimer); meetingSpeechTimer = null; activeMeetingSpeech = null; if (clearQueue) meetingSpeechQueue = []; document.querySelectorAll('.speech-bubble').forEach(item => item.remove()); document.querySelectorAll('.seat.active').forEach(item => item.classList.remove('active')); document.getElementById('conference-seats')?.classList.remove('has-speaker'); const stage = document.getElementById('conference-stage'); stage?.classList.remove('is-speaking'); stage?.style.removeProperty('--speaker-color'); updateMeetingSpeechState(); } function finishMeetingSpeech() { if (!activeMeetingSpeech) return; const { bubble, seat } = activeMeetingSpeech; activeMeetingSpeech = null; meetingSpeechTimer = null; bubble.classList.remove('active'); bubble.classList.add('leaving'); seat.classList.remove('active'); setTimeout(() => { bubble.remove(); if (meetingSpeechQueue.length > 0 && viewMode === 'meeting') playNextMeetingSpeech(); else { document.getElementById('conference-seats')?.classList.remove('has-speaker'); const stage = document.getElementById('conference-stage'); stage?.classList.remove('is-speaking'); stage?.style.removeProperty('--speaker-color'); const speaker = document.getElementById('meeting-feed-speaker'); const feed = document.getElementById('meeting-feed-text'); if (speaker) speaker.textContent = '本輪發言完成'; if (feed) feed.textContent = '所有觀點都已保存至對話紀錄,可繼續追問或提出新議題。'; updateMeetingSpeechState(); } }, 360); } function playNextMeetingSpeech() { if (activeMeetingSpeech || viewMode !== 'meeting') return; const item = meetingSpeechQueue.shift(); if (!item) { updateMeetingSpeechState(); return; } const seat = document.getElementById('seat-' + item.id); if (!seat) { playNextMeetingSpeech(); return; } document.querySelectorAll('.speech-bubble').forEach(bubble => bubble.remove()); window.setActiveSeat(item.id); document.getElementById('conference-seats')?.classList.add('has-speaker'); const stage = document.getElementById('conference-stage'); stage?.classList.add('is-speaking'); const employee = EMPLOYEES.find(candidate => candidate.id === item.id); if (stage && employee) stage.style.setProperty('--speaker-color', employee.color); const speaker = document.getElementById('meeting-feed-speaker'); const feed = document.getElementById('meeting-feed-text'); const cleanText = String(item.text || '').replace(/[#*_>]/g, '').replace(/\s+/g, ' ').trim(); if (speaker) speaker.textContent = employee ? employee.name + ' 正在發言' : '專家正在發言'; if (feed) feed.textContent = cleanText.slice(0, 120); updateMeetingSpeechState(employee); const bubble = document.createElement('div'); // V1.2 任務 4(c):總結氣泡只看佇列 item.isSummary(由 showSpeechBubble 呼叫端明確傳入), // 不再用內文關鍵字猜測,避免一般發言提到「重點」就被誤判為總結。 const isSummary = item.isSummary === true; bubble.className = 'speech-bubble ' + (isSummary ? 'summary' : ''); bubble.setAttribute('role', 'status'); bubble.setAttribute('aria-live', 'polite'); const visibleText = item.text.length > 720 ? item.text.slice(0, 720) + '…' : item.text; bubble.innerHTML = `
●${escapeHtml(employee?.name || 'AI 專家')}${escapeHtml(employee?.role || '發言')}
${renderMarkdown(visibleText)}
`; const useStageBubble = window.matchMedia('(max-width: 768px)').matches && stage; if (useStageBubble) { bubble.classList.add('mobile-speech-bubble'); bubble.style.setProperty('--seat-color', employee?.color || '#7c5cff'); stage.appendChild(bubble); } else { seat.appendChild(bubble); } activeMeetingSpeech = { bubble, seat, id: item.id }; requestAnimationFrame(() => bubble.classList.add('active')); const duration = Math.min(9000, Math.max(5200, 4200 + cleanText.length * 7)); meetingSpeechTimer = setTimeout(finishMeetingSpeech, duration); } // V1.2 任務 4(c):第 3 參數 options = { isSummary },由 respondOne 在總結回覆時傳入; // 一般輪次不傳,預設 false。舊呼叫 showSpeechBubble(id, txt) 行為不變。 function showSpeechBubble(id, txt, options = null) { const text = String(txt || '').trim(); if (!text || viewMode !== 'meeting') return; meetingSpeechQueue.push({ id, text, isSummary: options?.isSummary === true }); const currentEmployee = activeMeetingSpeech ? EMPLOYEES.find(employee => employee.id === activeMeetingSpeech.id) : null; updateMeetingSpeechState(currentEmployee); playNextMeetingSpeech(); } // ===== 設定功能 ===== window.toggleSettings = () => { const panel = document.getElementById('settings-panel'); if (!panel) return; panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; }; document.addEventListener('click', (e) => { const panel = document.getElementById('settings-panel'); if (!panel) return; if (!panel.contains(e.target) && !e.target.closest('[onclick="toggleSettings()"]')) { panel.style.display = 'none'; } }); window.toggleDarkMode = () => { document.body.classList.toggle('light-mode'); const isLight = document.body.classList.contains('light-mode'); const btn = document.getElementById('dark-mode-btn'); if (btn) btn.textContent = isLight ? '☀️ 淺色' : '🌙 深色'; }; window.setFontSize = (size) => { document.documentElement.style.setProperty('--font-size', size + 'px'); document.querySelectorAll('[data-sz]').forEach(btn => { const isActive = parseInt(btn.dataset.sz) === size; btn.style.background = isActive ? 'var(--accent)' : 'rgba(255,255,255,0.05)'; btn.style.borderColor = isActive ? 'var(--accent)' : 'rgba(255,255,255,0.1)'; btn.style.color = isActive ? 'white' : 'var(--text)'; }); }; // ===== 指定模式顯示目前對話對象 ===== const origSetMode = window.setMode; window.setMode = (m) => { if (origSetMode) origSetMode(m); updateTargetDisplay(); }; window.setTargetAI = (id) => { if (!EMPLOYEES.some(employee => employee.id === id)) return; targetAI = id; renderEmployees(); updateTargetDisplay(); updateModeContext(); }; function updateTargetDisplay() { const info = document.getElementById('target-display'); const prefix = document.getElementById('input-prefix'); const input = document.getElementById('msg-input'); if (!info) return; if (currentMode === 'target') { const emp = EMPLOYEES.find(e => e.id === targetAI); if (emp) { info.style.display = 'flex'; info.innerHTML = `🎯 對話對象: `; if (prefix) { prefix.textContent = emp.name + ' →'; prefix.style.color = emp.color; prefix.style.display = 'inline'; } if (input) input.style.paddingLeft = ((emp.name.length * 9) + 30) + 'px'; } } else { info.style.display = 'none'; if (prefix) prefix.style.display = 'none'; if (input) input.style.paddingLeft = '14px'; } } updateModeContext(); console.log('AI辦公室合併版載入完成 ✅');