diff --git a/core/commands/delivery.py b/core/commands/delivery.py new file mode 100644 index 0000000..eb37a88 --- /dev/null +++ b/core/commands/delivery.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import time + +from asgiref.sync import sync_to_async + +from core.clients import transport +from core.models import ChatSession, Message + +STATUS_VISIBLE_SOURCE_SERVICES = {"web", "xmpp"} + + +def chunk_for_transport(text: str, limit: int = 3000) -> list[str]: + body = str(text or "").strip() + if not body: + return [] + if len(body) <= limit: + return [body] + parts = [] + remaining = body + while len(remaining) > limit: + cut = remaining.rfind("\n\n", 0, limit) + if cut < int(limit * 0.45): + cut = remaining.rfind("\n", 0, limit) + if cut < int(limit * 0.35): + cut = limit + parts.append(remaining[:cut].rstrip()) + remaining = remaining[cut:].lstrip() + if remaining: + parts.append(remaining) + return [part for part in parts if part] + + +async def post_status_in_source(trigger_message: Message, text: str, origin_tag: str) -> bool: + service = str(trigger_message.source_service or "").strip().lower() + if service not in STATUS_VISIBLE_SOURCE_SERVICES: + return False + if service == "web": + await sync_to_async(Message.objects.create)( + user=trigger_message.user, + session=trigger_message.session, + sender_uuid="", + text=text, + ts=int(time.time() * 1000), + custom_author="BOT", + source_service="web", + source_chat_id=trigger_message.source_chat_id or "", + message_meta={"origin_tag": origin_tag}, + ) + return True + # For non-web, route through transport raw API. + if not str(trigger_message.source_chat_id or "").strip(): + return False + try: + await transport.send_message_raw( + service, + str(trigger_message.source_chat_id or "").strip(), + text=text, + attachments=[], + metadata={"origin_tag": origin_tag}, + ) + return True + except Exception: + return False + + +async def post_to_channel_binding( + trigger_message: Message, + binding_service: str, + binding_channel_identifier: str, + text: str, + origin_tag: str, + command_slug: str, +) -> bool: + service = str(binding_service or "").strip().lower() + channel_identifier = str(binding_channel_identifier or "").strip() + if service == "web": + session = None + if channel_identifier and channel_identifier == str( + trigger_message.source_chat_id or "" + ).strip(): + session = trigger_message.session + if session is None and channel_identifier: + session = await sync_to_async( + lambda: ChatSession.objects.filter( + user=trigger_message.user, + identifier__identifier=channel_identifier, + ) + .order_by("-last_interaction") + .first() + )() + if session is None: + session = trigger_message.session + await sync_to_async(Message.objects.create)( + user=trigger_message.user, + session=session, + sender_uuid="", + text=text, + ts=int(time.time() * 1000), + custom_author="BOT", + source_service="web", + source_chat_id=channel_identifier or str(trigger_message.source_chat_id or ""), + message_meta={"origin_tag": origin_tag}, + ) + return True + try: + chunks = chunk_for_transport(text, limit=3000) + if not chunks: + return False + for chunk in chunks: + ts = await transport.send_message_raw( + service, + channel_identifier, + text=chunk, + attachments=[], + metadata={ + "origin_tag": origin_tag, + "command_slug": command_slug, + }, + ) + if not ts: + return False + return True + except Exception: + return False diff --git a/core/commands/handlers/bp.py b/core/commands/handlers/bp.py index 5ded0ac..442db8f 100644 --- a/core/commands/handlers/bp.py +++ b/core/commands/handlers/bp.py @@ -5,15 +5,14 @@ import time from asgiref.sync import sync_to_async from django.conf import settings -from core.clients import transport from core.commands.base import CommandContext, CommandHandler, CommandResult +from core.commands.delivery import post_status_in_source, post_to_channel_binding from core.messaging import ai as ai_runner from core.messaging.utils import messages_to_string from core.models import ( AI, BusinessPlanDocument, BusinessPlanRevision, - ChatSession, CommandAction, CommandChannelBinding, CommandRun, @@ -60,56 +59,9 @@ def _bp_fallback_markdown(template_text: str, transcript: str, error_text: str = ) -def _chunk_for_transport(text: str, limit: int = 3000) -> list[str]: - body = str(text or "").strip() - if not body: - return [] - if len(body) <= limit: - return [body] - parts = [] - remaining = body - while len(remaining) > limit: - cut = remaining.rfind("\n\n", 0, limit) - if cut < int(limit * 0.45): - cut = remaining.rfind("\n", 0, limit) - if cut < int(limit * 0.35): - cut = limit - parts.append(remaining[:cut].rstrip()) - remaining = remaining[cut:].lstrip() - if remaining: - parts.append(remaining) - return [part for part in parts if part] - - class BPCommandHandler(CommandHandler): slug = "bp" - async def _status_message(self, trigger_message: Message, text: str): - service = str(trigger_message.source_service or "").strip().lower() - if service == "web": - await sync_to_async(Message.objects.create)( - user=trigger_message.user, - session=trigger_message.session, - sender_uuid="", - text=text, - ts=int(time.time() * 1000), - custom_author="BOT", - source_service="web", - source_chat_id=trigger_message.source_chat_id or "", - ) - return - if service == "xmpp" and str(trigger_message.source_chat_id or "").strip(): - try: - await transport.send_message_raw( - "xmpp", - str(trigger_message.source_chat_id or "").strip(), - text=text, - attachments=[], - metadata={"origin_tag": f"bp-status:{trigger_message.id}"}, - ) - except Exception: - return - async def _fanout(self, run: CommandRun, text: str) -> dict: profile = run.profile trigger = await sync_to_async( @@ -129,63 +81,17 @@ class BPCommandHandler(CommandHandler): sent_bindings = 0 failed_bindings = 0 for binding in bindings: - if binding.service == "web": - session = None - channel_identifier = str(binding.channel_identifier or "").strip() - if ( - channel_identifier - and channel_identifier == str(trigger.source_chat_id or "").strip() - ): - session = trigger.session - if session is None and channel_identifier: - session = await sync_to_async( - lambda: ChatSession.objects.filter( - user=trigger.user, - identifier__identifier=channel_identifier, - ) - .order_by("-last_interaction") - .first() - )() - if session is None: - session = trigger.session - await sync_to_async(Message.objects.create)( - user=trigger.user, - session=session, - sender_uuid="", - text=text, - ts=int(time.time() * 1000), - custom_author="BOT", - source_service="web", - source_chat_id=channel_identifier or str(trigger.source_chat_id or ""), - message_meta={"origin_tag": f"bp:{run.id}"}, - ) + ok = await post_to_channel_binding( + trigger_message=trigger, + binding_service=binding.service, + binding_channel_identifier=binding.channel_identifier, + text=text, + origin_tag=f"bp:{run.id}", + command_slug=self.slug, + ) + if ok: sent_bindings += 1 - continue - try: - chunks = _chunk_for_transport(text, limit=3000) - if not chunks: - failed_bindings += 1 - continue - ok = True - for chunk in chunks: - ts = await transport.send_message_raw( - binding.service, - binding.channel_identifier, - text=chunk, - attachments=[], - metadata={ - "origin_tag": f"bp:{run.id}", - "command_slug": "bp", - }, - ) - if not ts: - ok = False - break - if ok: - sent_bindings += 1 - else: - failed_bindings += 1 - except Exception: + else: failed_bindings += 1 return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings} @@ -357,7 +263,11 @@ class BPCommandHandler(CommandHandler): status_text += f" · fanout sent:{sent_count}" if failed_count: status_text += f" failed:{failed_count}" - await self._status_message(trigger, status_text) + await post_status_in_source( + trigger_message=trigger, + text=status_text, + origin_tag=f"bp-status:{trigger.id}", + ) run.status = "ok" run.result_ref = document diff --git a/core/templates/base.html b/core/templates/base.html index bc92840..eec83c5 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -6,7 +6,7 @@ - GIA - {{ request.path_info }} + {% block browser_title %}{{ request.resolver_match.url_name|default:request.path_info|cut:"_"|cut:"/"|cut:"-"|upper|slice:":3" }}{% endblock %} diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index ed4f4d1..31b36ba 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -97,6 +97,10 @@ Open command routing + + + +
+ Click one message for start, second for end. Third click starts a new range. +
+ + +
{% for msg in serialized_messages %} -
+
{% if msg.gap_fragments %} {% with gap=msg.gap_fragments.0 %}

{{ gap.lag|default:"-" }} @@ -507,17 +582,36 @@ gap: 0.2rem; background: rgba(247, 249, 252, 0.88); border: 1px solid rgba(103, 121, 145, 0.16); + position: relative; + --latency-pair-color: rgba(103, 121, 145, 0.45); + z-index: 2; } #{{ panel_id }} .compose-latency-val { font-weight: 600; color: #58667a; } - #{{ panel_id }} .compose-latency-chip::before, - #{{ panel_id }} .compose-latency-chip::after { - content: ""; - width: 0.95rem; - height: 1px; - background: rgba(101, 119, 141, 0.28); + #{{ panel_id }} .compose-latency-chip.is-pace-matched { + --latency-pair-color: rgba(43, 133, 74, 0.85); + border-color: rgba(43, 133, 74, 0.52); + background: rgba(234, 251, 240, 0.98); + color: #236140; + } + #{{ panel_id }} .compose-latency-chip.is-pace-fast { + --latency-pair-color: rgba(39, 98, 189, 0.82); + border-color: rgba(39, 98, 189, 0.45); + background: rgba(234, 244, 255, 0.98); + color: #234d8f; + } + #{{ panel_id }} .compose-latency-chip.is-pace-slow { + --latency-pair-color: rgba(191, 95, 36, 0.85); + border-color: rgba(191, 95, 36, 0.5); + background: rgba(255, 242, 232, 0.98); + color: #8e4620; + } + #{{ panel_id }} .compose-latency-chip.is-pace-matched .compose-latency-val, + #{{ panel_id }} .compose-latency-chip.is-pace-fast .compose-latency-val, + #{{ panel_id }} .compose-latency-chip.is-pace-slow .compose-latency-val { + color: inherit; } #{{ panel_id }} .compose-bubble { max-width: min(85%, 46rem); @@ -990,24 +1084,31 @@ align-items: center; gap: 0.3rem; max-width: 100%; - border: 1px solid rgba(0, 0, 0, 0.14); + border: 1px solid rgba(47, 79, 122, 0.32); border-radius: 999px; - padding: 0.12rem 0.45rem; - background: rgba(250, 252, 255, 0.95); + padding: 0.14rem 0.5rem; + background: #f5f9ff; font-size: 0.64rem; line-height: 1.2; min-width: 0; color: inherit; text-decoration: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); + position: relative; } #{{ panel_id }} .compose-glance-item.is-equal-size { - width: 10.6rem; - max-width: 10.6rem; + width: 12.8rem; + max-width: 12.8rem; justify-content: space-between; } #{{ panel_id }} .compose-glance-item.compose-glance-item-reply { gap: 0.22rem; } + #{{ panel_id }} .compose-glance-item.compose-glance-item-reply .compose-glance-val { + white-space: nowrap; + font-variant-numeric: tabular-nums; + flex: 0 0 auto; + } #{{ panel_id }} .compose-reply-mini-track { width: 2.35rem; height: 0.22rem; @@ -1034,6 +1135,11 @@ color: #5b6a7c; white-space: nowrap; } + #{{ panel_id }} .compose-glance-key::after { + content: ":"; + margin-left: 0.14rem; + color: #7a8ca3; + } #{{ panel_id }} .compose-glance-val { color: #26384f; font-weight: 700; @@ -1041,6 +1147,116 @@ overflow-wrap: anywhere; word-break: break-word; } + #{{ panel_id }} .compose-row.is-range-anchor .compose-bubble { + border-color: rgba(35, 84, 175, 0.68); + box-shadow: 0 0 0 2px rgba(35, 84, 175, 0.12); + } + #{{ panel_id }} .compose-row.is-range-selected .compose-bubble { + border-color: rgba(53, 124, 77, 0.44); + background: linear-gradient(180deg, rgba(239, 253, 244, 0.95), rgba(255, 255, 255, 0.98)); + } + #{{ panel_id }} .compose-export { + margin-top: 0.4rem; + margin-bottom: 0.45rem; + border: 1px solid rgba(0, 0, 0, 0.14); + border-radius: 8px; + padding: 0.5rem; + background: linear-gradient(180deg, rgba(248, 252, 255, 0.9), rgba(255, 255, 255, 0.98)); + } + #{{ panel_id }} .compose-export.is-hidden { + display: none; + } + #{{ panel_id }} .compose-export-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.4rem; + } + #{{ panel_id }} .compose-export-title { + margin: 0; + font-size: 0.75rem; + font-weight: 700; + color: #233651; + } + #{{ panel_id }} .compose-export-summary { + font-size: 0.68rem; + color: #5b6a7c; + } + #{{ panel_id }} .compose-export-controls { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + margin-bottom: 0.35rem; + } + #{{ panel_id }} .compose-export-fields { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: #fff; + padding: 0.18rem 0.34rem; + } + #{{ panel_id }} .compose-export-voice { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + background: #fff; + padding: 0.18rem 0.34rem; + } + #{{ panel_id }} .compose-export-voice > summary { + cursor: pointer; + font-size: 0.68rem; + color: #2b3d56; + user-select: none; + } + #{{ panel_id }} .compose-export-voice-grid { + margin-top: 0.28rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.2rem 0.6rem; + min-width: min(20rem, 72vw); + } + #{{ panel_id }} .compose-export-voice-col { + display: grid; + gap: 0.14rem; + } + #{{ panel_id }} .compose-export-voice-title { + margin: 0 0 0.08rem 0; + font-size: 0.64rem; + color: #5b6a7c; + font-weight: 700; + } + #{{ panel_id }} .compose-export-fields > summary { + cursor: pointer; + font-size: 0.68rem; + color: #2b3d56; + user-select: none; + } + #{{ panel_id }} .compose-export-fields-grid { + margin-top: 0.28rem; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.24rem 0.5rem; + min-width: min(26rem, 72vw); + } + #{{ panel_id }} .compose-export-presets { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + margin-bottom: 0.35rem; + } + #{{ panel_id }} .compose-export-help { + font-size: 0.64rem; + color: #657283; + } + #{{ panel_id }} .compose-export-buffer { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.72rem; + line-height: 1.35; + white-space: pre; + overflow: auto; + } #{{ panel_id }} .compose-status-line { margin: 0; font-size: 0.76rem; @@ -1487,6 +1703,17 @@ const lightboxImage = document.getElementById(panelId + "-lightbox-image"); const lightboxPrev = document.getElementById(panelId + "-lightbox-prev"); const lightboxNext = document.getElementById(panelId + "-lightbox-next"); + const exportToggle = panel.querySelector(".compose-export-toggle"); + const exportBox = document.getElementById(panelId + "-export"); + const exportSummary = document.getElementById(panelId + "-export-summary"); + const exportScope = document.getElementById(panelId + "-export-scope"); + const exportFormat = document.getElementById(panelId + "-export-format"); + const exportVoiceOut = Array.from(panel.querySelectorAll(".compose-export-voice-out")); + const exportVoiceIn = Array.from(panel.querySelectorAll(".compose-export-voice-in")); + const exportFieldChecks = Array.from(panel.querySelectorAll(".compose-export-field")); + const exportCopy = document.getElementById(panelId + "-export-copy"); + const exportClear = document.getElementById(panelId + "-export-clear"); + const exportBuffer = document.getElementById(panelId + "-export-buffer"); const csrfToken = "{{ csrf_token }}"; if (lightbox && lightbox.parentElement !== document.body) { document.body.appendChild(lightbox); @@ -1528,6 +1755,9 @@ seenMessageIds: new Set(), replyTimingTimer: null, replyTargetId: "", + rangeStartId: "", + rangeEndId: "", + rangeMode: "inside", }; window.giaComposePanels[panelId] = panelState; const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger")); @@ -2187,6 +2417,79 @@ chip.classList.toggle("is-over-target", !!replyTimingState.isOverTarget); }; + const resetLatencyPacingState = function (chip) { + if (!chip || !chip.classList) { + return; + } + chip.classList.remove( + "is-pace-matched", + "is-pace-fast", + "is-pace-slow" + ); + if (chip.dataset) { + chip.dataset.pacingNote = ""; + } + if (chip.dataset && chip.dataset.baseTitle !== undefined) { + chip.title = chip.dataset.baseTitle || ""; + } + }; + + const applyLatencyPacingPairs = function () { + if (!thread) { + return; + } + const chips = Array.from(thread.querySelectorAll(".compose-latency-chip")); + chips.forEach(function (chip) { + if (!chip.dataset.baseTitle) { + chip.dataset.baseTitle = String(chip.title || ""); + } + resetLatencyPacingState(chip); + }); + if (chips.length < 2) { + return; + } + for (let index = 1; index < chips.length; index += 1) { + const prev = chips[index - 1]; + const curr = chips[index]; + const prevMs = toInt(prev.dataset.lagMs || 0); + const currMs = toInt(curr.dataset.lagMs || 0); + const prevTurn = String(prev.dataset.turn || ""); + const currTurn = String(curr.dataset.turn || ""); + if (!prevMs || !currMs || !prevTurn || !currTurn || prevTurn === currTurn) { + continue; + } + const ratio = currMs / prevMs; + const pct = Math.max(0, Math.round(ratio * 100)); + let paceClass = "is-pace-matched"; + let paceLabel = "Matched turn pacing"; + if (pct < 80) { + paceClass = "is-pace-fast"; + paceLabel = "Faster than the previous turn"; + } else if (pct > 125) { + paceClass = "is-pace-slow"; + paceLabel = "Slower than the previous turn"; + } + if ( + !prev.classList.contains("is-pace-matched") + && !prev.classList.contains("is-pace-fast") + && !prev.classList.contains("is-pace-slow") + ) { + prev.classList.add(paceClass); + } + curr.classList.add(paceClass); + if (!String(prev.dataset.pacingNote || "").trim()) { + prev.dataset.pacingNote = "Pacing: " + paceLabel; + } + prev.title = [String(prev.dataset.baseTitle || ""), String(prev.dataset.pacingNote || "")] + .filter(Boolean) + .join(" | "); + curr.dataset.pacingNote = "Pacing: " + paceLabel; + curr.title = [String(curr.dataset.baseTitle || ""), "Pacing: " + paceLabel] + .filter(Boolean) + .join(" | "); + } + }; + const updateGlanceFromState = function () { const items = []; if (glanceState.gap) { @@ -2218,6 +2521,20 @@ url: insightUrlForMetric(metricSlug), }); }); + if (!glanceState.metrics || !glanceState.metrics.length) { + items.push({ + label: "Stability Score", + value: "n/a", + tooltip: "No stability score available yet for this conversation.", + url: "", + }); + items.push({ + label: "Stability Confidence", + value: "n/a", + tooltip: "No stability confidence available yet for this conversation.", + url: "", + }); + } renderGlanceItems(items); }; @@ -2263,6 +2580,8 @@ } const chip = document.createElement("p"); chip.className = "compose-latency-chip"; + chip.dataset.lagMs = String(gap.lag_ms || 0); + chip.dataset.turn = msg.outgoing ? "outgoing" : "incoming"; chip.title = latencyTooltip(gap); const icon = document.createElement("span"); icon.className = "icon is-small"; @@ -2317,6 +2636,12 @@ row.dataset.replySnippet = normalizeSnippet( msg.display_text || msg.text || (msg.image_url ? "" : "(no text)") ); + row.dataset.author = String(msg.author || ""); + row.dataset.displayTs = String(msg.display_ts || msg.ts || ""); + row.dataset.sourceService = String(msg.source_service || ""); + row.dataset.sourceLabel = String(msg.source_label || ""); + row.dataset.sourceMessageId = String(msg.source_message_id || ""); + row.dataset.direction = outgoing ? "outgoing" : "incoming"; if (msg.reply_to_id) { row.dataset.replyToId = String(msg.reply_to_id || ""); } @@ -2482,6 +2807,7 @@ } row.appendChild(bubble); insertRowByTs(row); + applyLatencyPacingPairs(); wireImageFallbacks(row); bindReplyReferences(row); updateGlanceFromMessage(msg); @@ -2556,6 +2882,16 @@ } return; } + const rowClick = ev.target.closest && ev.target.closest(".compose-row[data-message-id]"); + if ( + rowClick + && exportBox + && !exportBox.classList.contains("is-hidden") + && !ev.target.closest(".compose-reply-link") + ) { + setRangeAnchor(rowClick.dataset.messageId || ""); + return; + } const btn = ev.target.closest && ev.target.closest('.js-receipt-trigger'); if (!btn) return; if (activeReceiptBtn === btn && !receiptPopover.classList.contains('is-hidden')) { @@ -2631,6 +2967,7 @@ scrollToBottom(shouldStick); } updateReplyTimingUi(); + updateExportBuffer(); }; const applyTyping = function (typingPayload) { @@ -2866,6 +3203,344 @@ return thread.querySelector('.compose-row[data-message-id="' + key + '"]'); }; + const allMessageRows = function () { + return Array.from(thread.querySelectorAll(".compose-row[data-message-id]")); + }; + + const selectedRangeRows = function () { + const rows = allMessageRows(); + if (!rows.length || !panelState.rangeStartId) { + return []; + } + const startIndex = rows.findIndex(function (row) { + return String(row.dataset.messageId || "") === String(panelState.rangeStartId || ""); + }); + if (startIndex < 0) { + return []; + } + let endIndex = startIndex; + if (panelState.rangeEndId) { + const idx = rows.findIndex(function (row) { + return String(row.dataset.messageId || "") === String(panelState.rangeEndId || ""); + }); + if (idx >= 0) { + endIndex = idx; + } + } + const lo = Math.min(startIndex, endIndex); + const hi = Math.max(startIndex, endIndex); + if (String(panelState.rangeMode || "inside") === "outside") { + return rows.filter(function (_row, idx) { + return idx < lo || idx > hi; + }); + } + return rows.filter(function (_row, idx) { + return idx >= lo && idx <= hi; + }); + }; + + const updateRangeSelectionUi = function () { + const selectedSet = new Set( + selectedRangeRows().map(function (row) { + return String(row.dataset.messageId || ""); + }) + ); + allMessageRows().forEach(function (row) { + const id = String(row.dataset.messageId || ""); + row.classList.toggle("is-range-selected", selectedSet.has(id)); + row.classList.toggle("is-range-anchor", id === String(panelState.rangeStartId || "") || id === String(panelState.rangeEndId || "")); + }); + }; + + const messageRowsToExportRecords = function () { + return selectedRangeRows().map(function (row) { + const bodyNode = row.querySelector(".compose-body"); + const text = String(bodyNode ? bodyNode.textContent || "" : "").trim(); + const authorRaw = String(row.dataset.author || "").trim(); + const author = authorRaw || (row.classList.contains("is-out") ? "USER" : "CONTACT"); + const when = String(row.dataset.displayTs || "").trim(); + return { + message_id: String(row.dataset.messageId || ""), + ts: toInt(row.dataset.ts || 0), + sender: author, + time: when, + direction: String(row.dataset.direction || (row.classList.contains("is-out") ? "outgoing" : "incoming")), + source_service: String(row.dataset.sourceService || "").trim(), + source_label: String(row.dataset.sourceLabel || "").trim(), + source_message_id: String(row.dataset.sourceMessageId || "").trim(), + text: text || "(no text)", + }; + }); + }; + + const selectedExportFields = function () { + const defaults = ["text", "time", "sender"]; + const picked = exportFieldChecks + .filter(function (node) { return !!(node && node.checked); }) + .map(function (node) { return String(node.value || "").trim(); }) + .filter(Boolean); + return picked.length ? picked : defaults; + }; + + const timeFieldNode = function () { + return exportFieldChecks.find(function (node) { + return String(node.value || "").trim() === "time"; + }) || null; + }; + + const syncExportFieldLocks = function () { + const format = String(exportFormat && exportFormat.value ? exportFormat.value : "plain"); + const timeNode = timeFieldNode(); + if (!timeNode) { + return; + } + if (format === "share") { + timeNode.checked = true; + timeNode.disabled = true; + } else { + timeNode.disabled = false; + } + }; + + const orderedExportFields = function (fields) { + const seen = new Set(); + const input = Array.isArray(fields) ? fields.slice() : []; + const normalized = input.filter(function (field) { + const key = String(field || "").trim(); + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + const priority = ["time", "sender"]; + const ordered = []; + priority.forEach(function (key) { + if (normalized.includes(key)) { + ordered.push(key); + } + }); + normalized.forEach(function (key) { + if (!ordered.includes(key)) { + ordered.push(key); + } + }); + return ordered; + }; + + const selectedVoiceLabel = function (nodes, fallback) { + const match = (nodes || []).find(function (node) { + return !!(node && node.checked); + }); + return String(match && match.value ? match.value : fallback || "").trim(); + }; + + const buildShareStatements = function (records) { + const outLabel = selectedVoiceLabel(exportVoiceOut, "I said"); + const inLabel = selectedVoiceLabel(exportVoiceIn, "They said"); + const lines = []; + let previous = null; + records.forEach(function (row) { + const speaker = row.direction === "outgoing" ? "you" : "they"; + const speakerLabel = speaker === "you" ? (outLabel + ": ") : (inLabel + ": "); + const timePrefix = row.time ? ("[" + row.time + "] ") : ""; + if (!previous) { + lines.push(timePrefix + speakerLabel + row.text); + previous = row; + return; + } + const deltaMs = Math.max(0, toInt(row.ts) - toInt(previous.ts)); + const waitLabel = formatElapsedCompact(deltaMs || 0); + const phrasing = speaker === "you" + ? ("After " + waitLabel + ", " + outLabel + ": ") + : ("After " + waitLabel + ", " + inLabel + ": "); + lines.push(timePrefix + phrasing + row.text); + previous = row; + }); + return lines.join("\n"); + }; + + const toCsvCell = function (value) { + const text = String(value || ""); + if (!/[\",\n]/.test(text)) { + return text; + } + return '"' + text.replace(/"/g, '""') + '"'; + }; + + const exportTextForRecords = function (records) { + const format = String(exportFormat && exportFormat.value ? exportFormat.value : "plain"); + const fields = orderedExportFields(selectedExportFields()); + if (!records.length) { + return ""; + } + if (format === "share") { + return buildShareStatements(records); + } + if (format === "jsonl") { + return records.map(function (row) { + const payload = {}; + fields.forEach(function (field) { + payload[field] = row[field]; + }); + return JSON.stringify(payload); + }).join("\n"); + } + if (format === "csv") { + const header = fields.slice(); + const rows = [header.map(toCsvCell).join(",")]; + records.forEach(function (row) { + const line = fields.map(function (field) { + return toCsvCell(row[field]); + }); + rows.push(line.join(",")); + }); + return rows.join("\n"); + } + if (format === "markdown") { + return records.map(function (row) { + const meta = fields.filter(function (field) { return field !== "text"; }).map(function (field) { + const value = row[field]; + if (value === undefined || value === null || value === "") { + return ""; + } + if (field === "time") { + return "[" + String(value) + "]"; + } + return "**" + field + "**=" + String(value); + }).filter(Boolean).join(" "); + const text = fields.includes("text") ? String(row.text || "") : ""; + if (meta && text) { + return "- " + meta + " | " + text; + } + return "- " + (meta || text); + }).join("\n"); + } + return records.map(function (row) { + const parts = []; + fields.forEach(function (field) { + const value = row[field]; + if (value === undefined || value === null || value === "") { + return; + } + if (field === "text") { + parts.push(String(value)); + } else if (field === "time") { + parts.push("[" + String(value) + "]"); + } else { + parts.push(field + "=" + String(value)); + } + }); + return parts.join(" "); + }).join("\n"); + }; + + const updateExportBuffer = function () { + if (!exportBuffer) { + return; + } + const records = messageRowsToExportRecords(); + exportBuffer.value = exportTextForRecords(records); + if (exportSummary) { + const count = records.length; + const modeLabel = String(panelState.rangeMode || "inside"); + if (!panelState.rangeStartId) { + exportSummary.textContent = "Select two messages to define a range."; + } else if (!panelState.rangeEndId) { + exportSummary.textContent = "Start set. Pick an end message."; + } else { + exportSummary.textContent = count + " messages selected (" + modeLabel + ")."; + } + } + updateRangeSelectionUi(); + }; + + const setRangeAnchor = function (messageId) { + const id = String(messageId || "").trim(); + if (!id) { + return; + } + if (!panelState.rangeStartId || (panelState.rangeStartId && panelState.rangeEndId)) { + panelState.rangeStartId = id; + panelState.rangeEndId = ""; + } else if (panelState.rangeStartId && !panelState.rangeEndId) { + panelState.rangeEndId = id; + } + updateExportBuffer(); + }; + + const clearRangeSelection = function () { + panelState.rangeStartId = ""; + panelState.rangeEndId = ""; + updateExportBuffer(); + }; + + const initExportUi = function () { + updateExportBuffer(); + if (exportToggle && exportBox) { + exportToggle.addEventListener("click", function () { + const hidden = exportBox.classList.toggle("is-hidden"); + exportToggle.setAttribute("aria-expanded", hidden ? "false" : "true"); + if (!hidden && exportBuffer) { + exportBuffer.focus(); + exportBuffer.select(); + } + }); + } + if (exportScope) { + exportScope.addEventListener("change", function () { + panelState.rangeMode = String(exportScope.value || "inside"); + updateExportBuffer(); + }); + } + if (exportFormat) { + exportFormat.addEventListener("change", function () { + syncExportFieldLocks(); + updateExportBuffer(); + }); + } + exportVoiceOut.forEach(function (node) { node.addEventListener("change", updateExportBuffer); }); + exportVoiceIn.forEach(function (node) { node.addEventListener("change", updateExportBuffer); }); + exportFieldChecks.forEach(function (node) { + node.addEventListener("change", updateExportBuffer); + }); + if (exportCopy) { + exportCopy.addEventListener("click", async function () { + if (!exportBuffer) { + return; + } + const text = String(exportBuffer.value || ""); + if (!text) { + setStatus("No export text available for current selection.", "warning"); + return; + } + try { + await navigator.clipboard.writeText(text); + setStatus("Copied selected conversation export.", "success"); + } catch (err) { + exportBuffer.focus(); + exportBuffer.select(); + setStatus("Clipboard blocked. Press Ctrl+C to copy selected export text.", "warning"); + } + }); + } + if (exportClear) { + exportClear.addEventListener("click", function () { + clearRangeSelection(); + }); + } + if (exportBuffer) { + exportBuffer.addEventListener("keydown", function (event) { + if ((event.ctrlKey || event.metaKey) && String(event.key || "").toLowerCase() === "a") { + event.preventDefault(); + exportBuffer.select(); + } + }); + } + syncExportFieldLocks(); + updateExportBuffer(); + }; + const flashReplyTarget = function (row) { if (!row) { return; @@ -2967,6 +3642,7 @@ }); }; bindReplyReferences(panel); + initExportUi(); if (replyClearBtn) { replyClearBtn.addEventListener("click", function () { clearReplyTarget(); @@ -3045,6 +3721,7 @@ metaLine.textContent = titleCase(service) + " · " + identifier; } clearReplyTarget(); + clearRangeSelection(); if (panelState.socket) { try { panelState.socket.close(); @@ -3780,10 +4457,10 @@ document.addEventListener("keydown", panelState.lightboxKeyHandler); } panelState.resizeHandler = function () { - if (!popover || popover.classList.contains("is-hidden")) { - return; + if (popover && !popover.classList.contains("is-hidden")) { + positionPopover(panelState.activePanel); } - positionPopover(panelState.activePanel); + applyLatencyPacingPairs(); }; window.addEventListener("resize", panelState.resizeHandler); document.addEventListener("mousedown", panelState.docClickHandler); @@ -4008,6 +4685,7 @@ document.body.addEventListener("composeSendResult", panelState.sendResultHandler); hydrateBodyUrlsAsImages(thread); + applyLatencyPacingPairs(); updateReplyTimingUi(); panelState.replyTimingTimer = setInterval(updateReplyTimingUi, 1000); scrollToBottom(true); diff --git a/core/views/compose.py b/core/views/compose.py index 32c0da3..ac0fb3a 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -440,10 +440,14 @@ def _serialize_message(msg: Message) -> dict: read_delta = int(read_ts - ts_val) if read_ts and ts_val else None # Human friendly delta strings delivered_delta_display = ( - _format_gap_duration(delivered_delta) if delivered_delta is not None else "" + _format_gap_duration(delivered_delta) + if delivered_delta is not None and int(delivered_delta) > 0 + else "" ) read_delta_display = ( - _format_gap_duration(read_delta) if read_delta is not None else "" + _format_gap_duration(read_delta) + if read_delta is not None and int(read_delta) > 0 + else "" ) # Receipt payload and metadata receipt_payload = msg.receipt_payload or {} @@ -850,8 +854,11 @@ def _serialize_messages_with_artifacts( and current_ts >= prev_ts ): block_gap_ms = current_ts - prev_ts - serialized[idx]["block_gap_ms"] = int(block_gap_ms) - serialized[idx]["block_gap_display"] = _format_gap_duration(block_gap_ms) + if int(block_gap_ms) > 0: + serialized[idx]["block_gap_ms"] = int(block_gap_ms) + serialized[idx]["block_gap_display"] = _format_gap_duration( + block_gap_ms + ) if ( prev_msg is not None @@ -937,6 +944,23 @@ def _glance_items_from_state(gap_fragment=None, metric_fragments=None, person_id "url": _insight_detail_url(person_id, metric.get("slug")), } ) + if not metric_fragments: + items.extend( + [ + { + "label": "Stability Score", + "value": "n/a", + "tooltip": "No stability score available yet for this conversation.", + "url": "", + }, + { + "label": "Stability Confidence", + "value": "n/a", + "tooltip": "No stability confidence available yet for this conversation.", + "url": "", + }, + ] + ) return items[:3] @@ -1767,7 +1791,11 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di def _compose_urls(service, identifier, person_id): - query = {"service": service, "identifier": identifier} + service_key = _default_service(service) + identifier_value = str(identifier or "").strip() + if service_key == "whatsapp" and "@" in identifier_value: + identifier_value = identifier_value.split("@", 1)[0].strip() + query = {"service": service_key, "identifier": identifier_value} if person_id: query["person"] = str(person_id) payload = urlencode(query) @@ -1997,14 +2025,26 @@ def _recent_manual_contacts( if not all_rows: return [] + def _normalize_recent_identifier(service_value: str, identifier_value: str) -> str: + svc = _default_service(service_value) + raw = str(identifier_value or "").strip() + if svc == "whatsapp" and "@" in raw: + return raw.split("@", 1)[0].strip() + return raw + current_service_key = _default_service(current_service) - current_identifier_value = str(current_identifier or "").strip() + current_identifier_value = _normalize_recent_identifier( + current_service_key, str(current_identifier or "").strip() + ) current_person_id = str(current_person.id) if current_person else "" row_by_key = { ( str(row.get("service") or "").strip().lower(), - str(row.get("identifier") or "").strip(), + _normalize_recent_identifier( + str(row.get("service") or "").strip().lower(), + str(row.get("identifier") or "").strip(), + ), ): row for row in all_rows } @@ -2019,7 +2059,9 @@ def _recent_manual_contacts( if not person_id: continue service_key = _default_service(link.service) - identifier_value = str(link.identifier or "").strip() + identifier_value = _normalize_recent_identifier( + service_key, str(link.identifier or "").strip() + ) if not identifier_value: continue by_person_service.setdefault(person_id, {}) @@ -2042,9 +2084,13 @@ def _recent_manual_contacts( .order_by("-ts", "-id")[:1000] ) for service_value, identifier_value in recent_values: + service_key = _default_service(service_value) + normalized_identifier = _normalize_recent_identifier( + service_key, str(identifier_value or "").strip() + ) key = ( - _default_service(service_value), - str(identifier_value or "").strip(), + service_key, + normalized_identifier, ) if not key[1] or key in seen_keys: continue @@ -2113,6 +2159,9 @@ def _recent_manual_contacts( ).strip() break selected_identifier = selected_identifier or identifier_value + selected_identifier = _normalize_recent_identifier( + selected_service, selected_identifier + ) selected_urls = _compose_urls( selected_service, selected_identifier, @@ -2134,6 +2183,7 @@ def _recent_manual_contacts( svc_identifier = str( (service_map.get(svc) or {}).get("identifier") or "" ).strip() + svc_identifier = _normalize_recent_identifier(svc, svc_identifier) row[f"{svc}_identifier"] = svc_identifier if svc_identifier: svc_urls = _compose_urls(svc, svc_identifier, person_id) @@ -2150,7 +2200,9 @@ def _recent_manual_contacts( row["service_label"] = _service_label(service_key) for svc in ("signal", "whatsapp", "instagram", "xmpp"): row[f"{svc}_identifier"] = ( - identifier_value if svc == service_key else "" + _normalize_recent_identifier(service_key, identifier_value) + if svc == service_key + else "" ) row[f"{svc}_compose_url"] = ( row.get("compose_url") if svc == service_key else "" @@ -2161,7 +2213,11 @@ def _recent_manual_contacts( row["is_active"] = ( row.get("service") == current_service_key - and str(row.get("identifier") or "").strip() == current_identifier_value + and _normalize_recent_identifier( + str(row.get("service") or "").strip().lower(), + str(row.get("identifier") or "").strip(), + ) + == current_identifier_value ) rows.append(row) if len(rows) >= limit: