// Drives the 实时 / 待办 / 问它 tabs. Connects to the backend SSE + APIs. (function () { const $ = (id) => document.getElementById(id); const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); const hhmm = (ts) => { const d = new Date(ts); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; let state = null, lastOtherChat = ''; // ---------- space config (/api/space): every name/label comes from here ---------- let SPACE = {}; let ctxApplied = false, isBiz = false; const meN = () => (SPACE.me && SPACE.me.name) || '我'; const tgN = () => (SPACE.target && SPACE.target.name) || '对方'; const tgP = () => (SPACE.target && SPACE.target.pronoun) || 'TA'; const orgN = () => (SPACE.org && SPACE.org.name) || SPACE.name || '团队'; const hasPlugin = (p) => Array.isArray(SPACE.plugins) && SPACE.plugins.includes(p); function applySpaceLabels() { const setAll = (sel, txt) => document.querySelectorAll(sel).forEach((el) => { el.textContent = txt; }); setAll('.me-name', meN()); setAll('.tg-name', tgN()); setAll('.tg-pr', tgP()); setAll('.org-name', orgN()); const title = SPACE.name || 'Relate'; document.title = title; const h1 = document.querySelector('header h1'); if (h1 && !isBiz) h1.textContent = title; // chart series labels (charts are created empty in index.html) if (window.Chart && Chart.getChart) ['cMonthly', 'cSorry', 'cLove'].forEach((id) => { const c = Chart.getChart(id); if (!c) return; if (c.data.datasets[0]) c.data.datasets[0].label = meN(); if (c.data.datasets[1]) c.data.datasets[1].label = tgN(); c.update('none'); }); } const spaceReady = fetch('/api/space').then((r) => r.json()).then((s) => { SPACE = s || {}; }).catch(() => {}).then(() => applySpaceLabels()); // ---------- KPI tiles (live) ---------- function kpis(d) { const r = d.rel || {}; const w = r.waiting; const waitTxt = w ? (w.whoShouldReply === 'G' ? `你该回复(已 ${w.minutes} 分钟)` : `等 ${tgN()} 回复(${w.minutes} 分钟)`) : '—'; const waitColor = w && w.whoShouldReply === 'G' && w.minutes > 20 ? 'var(--warn)' : 'var(--ink)'; const ci = r.conflictToday || 0; const ciColor = ci >= 30 ? 'var(--warn)' : ci >= 10 ? '#9a7b3f' : 'var(--calm)'; const tiles = [ { n: `${r.gToday || 0} : ${r.eToday || 0}`, l: `今日消息 ${esc(meN())} : ${esc(tgN())}` }, { n: ci, l: '今日冲突强度', c: ciColor }, { n: `${r.sorryG || 0} : ${r.sorryE || 0}`, l: `今日"对不起" ${esc(meN())} : ${esc(tgN())}` }, { n: r.scriptG || 0, l: `剧本指数(${esc(meN())} 模板句)` }, { n: r.testE || 0, l: `测试指数(${esc(tgN())} 试探句)` }, { n: waitTxt, l: '当前谁该回复', c: waitColor, small: true }, ]; $('liveKpis').innerHTML = tiles.map((t) => `
${esc(t.n)}
${t.l}
` ).join(''); } // ---------- message bubble ---------- function bubble(m) { const isG = m.isSend === 1; const tag = m._tag || {}; const el = document.createElement('div'); el.className = 'bub ' + (isG ? 'bG' : 'bE'); let tags = ''; if (tag.conflict > 0) tags += `冲突`; if (tag.scriptG && isG) tags += `剧本`; if (m.voice) tags += `语音`; if (tag.care) tags += `⚠`; el.innerHTML = `
${esc(isG ? meN() : tgN())} · ${hhmm(m.ts)}${tags}
${esc(m.content)}
`; return el; } const seenMsg = new Set(); function pushFeed(m) { const sig = `${m.isSend}|${Math.floor(m.ts / 1000)}|${String(m.content).slice(0, 40)}`; if (seenMsg.has(sig)) return; seenMsg.add(sig); const f = $('liveFeed'); const atBottom = f.scrollTop + f.clientHeight >= f.scrollHeight - 40; f.appendChild(bubble(m)); while (f.children.length > 120) f.removeChild(f.firstChild); if (atBottom) f.scrollTop = f.scrollHeight; } function pushOther(o) { const f = $('otherFeed'); const none = $('othernone'); if (none) none.remove(); const el = document.createElement('div'); el.className = 'bub bE'; el.style.maxWidth = '100%'; el.innerHTML = `
${esc(o.sessionName)} · ${esc(o.senderName || '')} · ${hhmm(o.ts)}
${esc(o.content)}
`; f.appendChild(el); while (f.children.length > 40) f.removeChild(f.firstChild); f.scrollTop = f.scrollHeight; } // ---------- AI briefing ---------- function brief(ins, at) { if (!ins) return; const al = (ins.alerts || []).filter(Boolean); $('liveBrief').innerHTML = `
${esc(ins.headline || '')}
${esc(ins.summary || '')}
${ins.mood ? `
情绪 · ${esc(ins.mood)}
` : ''}
${(ins.activeTopics || []).map((t) => `${esc(t)}`).join('')}
${al.length ? `
${al.map((a) => `
⚠ ${esc(a)}
`).join('')}
` : ''}`; if (at) $('lbriefage').textContent = '更新于 ' + hhmm(at); } // ---------- care banner ---------- let careDismissed = 0; function care(c) { if (!c || !c.at) return; if (Date.now() - c.at > 12 * 3600000) { $('careBanner').style.display = 'none'; return; } // stale => hide if (c.at <= careDismissed) return; // dismissed this one const b = $('careBanner'); b.style.display = 'block'; const when = new Date(c.at).toLocaleString('zh-CN'); b.innerHTML = `

⚠ 需要认真对待 ✕ 关闭

${when} 出现了超出普通吵架范畴的表达:"${esc(c.text)}"。这不该被当作气话。若情绪持续,请优先安全,认真谈一谈或寻求专业帮助。

求助:Lifeline 13 11 14 · Beyond Blue 1300 22 4636 · 中国心理援助 12356
`; $('careX').onclick = () => { careDismissed = c.at; b.style.display = 'none'; }; } // ---------- todos ---------- const KMETA = { reply: ['需回复', 'reply'], sensitive: ['敏感点', 'sensitive'], promise: ['承诺', 'promise'], discuss: ['需讨论', 'discuss'] }; function scoreColor(s) { return s >= 75 ? 'var(--warn)' : s >= 55 ? '#c98a3f' : s >= 35 ? 'var(--g)' : 'var(--sub)'; } function rankRow(t) { const who = t.who === 'me' ? '你欠的' : `${esc(tgN())} 在意`; return `
${t.score ?? '·'}
${(KMETA[t.kind] || [''])[0]} ${esc(t.text)}
严重 ${t.severity || '-'}/5时效 ${{ high: '高', medium: '中', low: '低' }[t.urgency] || '-'}提及 ×${t.seen || 1}情绪 ${t.emotion || '-'}/5· ${who}
${t.status === 'done' ? '✓' : ''}
`; } let todoExpanded = false; function todos(r) { const ranked = r.topRanked || []; $('todoRanked').innerHTML = ranked.length ? ranked.map(rankRow).join('') : '
分析中…
'; const rest = r.rest || []; if ($('todoRestList')) $('todoRestList').innerHTML = rest.length ? rest.map(rankRow).join('') : '
没有更多了
'; if ($('restCnt')) $('restCnt').textContent = rest.length ? rest.length + ' 条' : ''; const moreLabel = () => `${todoExpanded ? '▴ 收起' : '▾ 展开'}其余待办与分类明细${rest.length ? '(' + rest.length + ')' : ''}`; if ($('todoRest')) $('todoRest').style.display = todoExpanded ? 'block' : 'none'; if ($('todoMore')) { $('todoMore').textContent = moreLabel(); $('todoMore').onclick = () => { todoExpanded = !todoExpanded; $('todoRest').style.display = todoExpanded ? 'block' : 'none'; $('todoMore').textContent = moreLabel(); }; } const g = r.groups || {}; const order = ['reply', 'promise', 'discuss']; // 敏感点 rendered separately from Living State const cols = order.map((k) => { const items = g[k] || []; const rows = items.length ? items.map(todoRow).join('') : '
暂无
'; return `

${KMETA[k][0]} ${labelFor(k)} ${items.length}

${rows}
`; }); $('todoGrid').innerHTML = cols.join('') + `

敏感点 回复时的雷区 来自关系记忆

加载中…
`; renderSensitive(); // auto-resolved stash (addressed / stale) const stash = r.stashed || []; if ($('todoStash')) { $('todoStash').style.display = stash.length ? 'block' : 'none'; $('stashList').innerHTML = stash.map((t) => { const tag = t.status === 'addressed' ? '✓ 已处理' : '💤 已过时'; return `
✓
${esc(t.text)}
${tag}${t.reason ? `· ${esc(t.reason)}` : ''}· ${(KMETA[t.kind] || [''])[0]}
`; }).join(''); } bindChecks(); } function labelFor(k) { return { reply: '对方在等你回', sensitive: '回复时的雷区', promise: '许下的承诺', discuss: '需要认真谈' }[k]; } // 敏感点 = Living State memory (landmines + watchNow) — higher quality than re-extracting let livingMem = null; function renderSensitive() { if (!$('sensitiveList')) return; const m = livingMem || {}; const items = [...(m.landmines || []).map((t) => ['雷', t]), ...(m.watchNow || []).map((t) => ['注意', t])]; $('sensitiveList').innerHTML = items.length ? items.map(([tag, t]) => `
${esc(t)}
${tag}
`).join('') : '
暂无(需要一次深度分析)
'; } async function pollLiving() { try { const d = await (await fetch('/api/living')).json(); livingMem = d.memory || {}; renderSensitive(); } catch {} } pollLiving(); setInterval(pollLiving, 30000); // ---- relationship trend strip (last N weeks, from real message tags) ---- function sparkline(arr, color, range) { const w = 62, h = 20; const pts = arr.map((x, i) => ({ i, v: x })).filter((p) => p.v != null); // real points only — nulls are gaps, NOT zeros const N = Math.max(arr.length - 1, 1); const svg = (inner) => `${inner}`; if (!pts.length) return svg(''); if (pts.length < 2) { const p = pts[pts.length - 1]; return svg(``); } // one point = dot, no fake line const vals = pts.map((p) => p.v); let min = range ? range[0] : Math.min(...vals), max = range ? range[1] : Math.max(...vals); const span = (max - min) || 1; const poly = pts.map((p) => `${((p.i / N) * w).toFixed(1)},${(h - ((p.v - min) / span) * (h - 4) - 2).toFixed(1)}`).join(' '); return svg(``); } async function renderTrends() { const box = $('trendStrip'); if (!box) return; let t; try { t = await (await fetch('/api/trends')).json(); } catch { return; } if (!t || !t.series || !t.weeks || t.weeks.length < 2) { box.style.display = 'none'; return; } const pct = (x) => (x == null ? '—' : x); const P = esc(tgP()); const items = [ { k: 'herSent', label: `${P}情绪`, badUp: false, fmt: pct, range: [-1, 1] }, { k: 'herInitPct', label: `${P}主动%`, badUp: false, fmt: pct }, { k: 'conflict', label: '冲突', badUp: true }, { k: 'sweet', label: '甜度%', badUp: false, fmt: pct }, { k: 'msgs', label: '互动量', badUp: false }, { k: 'nightPct', label: '深夜%', badUp: true, fmt: pct }, { k: 'latMin', label: '你回复(分)', badUp: true, fmt: (x) => (x == null ? '—' : Math.round(x)) }, { k: 'herLatMin', label: `${P}回复(分)`, badUp: true, fmt: (x) => (x == null ? '—' : Math.round(x)) }, { k: 'love', label: '示爱', badUp: false }, { k: 'sorryG', label: '你道歉', badUp: true }, ]; box.style.display = ''; box.innerHTML = `

📈 近${t.weeks.length}周趋势 每格一周 · 最右=本周

` + items.map((it) => { const arr = t.series[it.k] || [], arw = (t.arrows[it.k]) || '→'; const nonNull = arr.filter((x) => x != null).length; const last = arr[arr.length - 1], val = it.fmt ? it.fmt(last) : (last ?? 0); const cls = nonNull < 2 ? 'flat' : arw === '↑' ? (it.badUp ? 'up' : 'down') : arw === '↓' ? (it.badUp ? 'down' : 'up') : 'flat'; const color = cls === 'up' ? '#c0392b' : cls === 'down' ? '#2a9d54' : '#9aa0ac'; const tail = nonNull < 2 ? '·积累中' : `${val} ${arw}`; const head = nonNull < 2 ? `${val}` : ''; return `
${it.label}${sparkline(arr, color, it.range)}${head}${tail}
`; }).join('') + '
'; } renderTrends(); setInterval(renderTrends, 5 * 60000); function todoRow(t) { const who = esc(t.who === 'me' ? '你' : (t.scope === 'main' ? tgN() : (t.chatLabel || '对方'))); const cred = t.kind === 'promise' && t.credible !== undefined ? `${t.credible ? '较可兑现' : '偏空头'}` : ''; const scope = t.scope === 'other' ? `· ${esc(t.chatLabel || '')}` : ''; return `
${esc(t.text)}
▲${t.score ?? '-'}${who}${cred}${t.urgency === 'high' ? '紧要' : ''}${t.due ? `⏱ ${esc(t.due)}` : ''}${scope}
`; } function bindChecks() { document.querySelectorAll('#todo .chk').forEach((el) => el.onclick = () => { const id = el.dataset.id || (el.closest('[data-id]') && el.closest('[data-id]').dataset.id); if (id) fetch(`/api/reminders/${id}/toggle`, { method: 'POST' }); }); } // ---------- reply drafts ---------- async function draft(scope, btn, out) { btn.disabled = true; const old = btn.textContent; btn.innerHTML = ' 生成中…'; try { const url = scope === 'main' ? '/api/reply-preview?scope=main' : `/api/reply-preview?scope=other&chat=${encodeURIComponent(lastOtherChat)}`; const d = await (await fetch(url)).json(); if (d.error) { out.innerHTML = `
${esc(d.error)}
`; } else out.innerHTML = `
${esc(d.draft)}
${d.tone ? `语气:${esc(d.tone)} ` : ''}${d.note ? `
💡 ${esc(d.note)}` : ''}
`; } catch (e) { out.innerHTML = `
生成失败:${esc(e.message)}
`; } btn.disabled = false; btn.textContent = old; } $('genMain').onclick = () => draft('main', $('genMain'), $('draftMain')); $('genOther').onclick = () => draft('other', $('genOther'), $('draftOther')); // ---------- lightweight markdown: headings, **bold**, *italic*, `code`, bullets, tables, ---, [n] ---------- function mdInline(s) { return esc(s) .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/(^|[^*])\*(?!\s)([^*]+?)\*(?!\*)/g, '$1$2') .replace(/`([^`]+?)`/g, '$1') .replace(/\[(\d+)\]/g, '[$1]'); } const isTableRow = (l) => /^\|.*\|\s*$/.test(l); const isTableSep = (l) => /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(l); const cells = (l) => l.replace(/^\||\|\s*$/g, '').split('|').map((c) => c.trim()); function mdLite(t) { const lines = String(t || '').replace(/\r/g, '').split('\n'); let html = '', inList = false; const closeList = () => { if (inList) { html += ''; inList = false; } }; for (let i = 0; i < lines.length; i++) { const l = lines[i].trim(); // table: a row followed by a --- separator if (isTableRow(l) && i + 1 < lines.length && isTableSep(lines[i + 1].trim())) { closeList(); const head = cells(l); i += 2; let rows = ''; while (i < lines.length && isTableRow(lines[i].trim())) { rows += '' + cells(lines[i].trim()).map((c) => `${mdInline(c)}`).join('') + ''; i++; } i--; html += `
${head.map((c) => ``).join('')}${rows}
${mdInline(c)}
`; continue; } if (!l) { closeList(); continue; } if (/^---+$/.test(l) || /^___+$/.test(l)) { closeList(); html += '
'; } else if (/^#{1,6}\s/.test(l)) { closeList(); const lvl = (l.match(/^#+/)[0].length); html += `
${mdInline(l.replace(/^#{1,6}\s/, ''))}
`; } else if (/^>\s?/.test(l)) { closeList(); html += `
${mdInline(l.replace(/^>\s?/, ''))}
`; } else if (/^[-*+]\s/.test(l)) { if (!inList) { html += '