Allow sharing conversations
This commit is contained in:
125
core/commands/delivery.py
Normal file
125
core/commands/delivery.py
Normal file
@@ -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
|
||||
@@ -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,64 +81,18 @@ 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="",
|
||||
ok = await post_to_channel_binding(
|
||||
trigger_message=trigger,
|
||||
binding_service=binding.service,
|
||||
binding_channel_identifier=binding.channel_identifier,
|
||||
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}"},
|
||||
origin_tag=f"bp:{run.id}",
|
||||
command_slug=self.slug,
|
||||
)
|
||||
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:
|
||||
failed_bindings += 1
|
||||
return {"sent_bindings": sent_bindings, "failed_bindings": failed_bindings}
|
||||
|
||||
async def execute(self, ctx: CommandContext) -> CommandResult:
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GIA - {{ request.path_info }}</title>
|
||||
<title>{% block browser_title %}{{ request.resolver_match.url_name|default:request.path_info|cut:"_"|cut:"/"|cut:"-"|upper|slice:":3" }}{% endblock %}</title>
|
||||
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
|
||||
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
<a class="compose-command-settings-link" href="{% url 'command_routing' %}">Open command routing</a>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button is-light is-rounded compose-export-toggle" aria-expanded="false">
|
||||
<span class="icon is-small"><i class="fa-solid fa-layer-group"></i></span>
|
||||
<span>Select/Export</span>
|
||||
</button>
|
||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||
<span>Drafts</span>
|
||||
@@ -252,6 +256,75 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="{{ panel_id }}-export" class="compose-export is-hidden">
|
||||
<div class="compose-export-head">
|
||||
<p class="compose-export-title">Conversation Range Export</p>
|
||||
<span id="{{ panel_id }}-export-summary" class="compose-export-summary">Select two messages to define a range.</span>
|
||||
</div>
|
||||
<div class="compose-export-controls">
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-export-scope">
|
||||
<option value="inside">Inside Range</option>
|
||||
<option value="outside">Outside Range</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select is-small">
|
||||
<select id="{{ panel_id }}-export-format">
|
||||
<option value="plain">Plain Text</option>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="share">Share Statements</option>
|
||||
<option value="jsonl">JSONL</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="compose-export-voice">
|
||||
<summary>Speaker Labels</summary>
|
||||
<div class="compose-export-voice-grid">
|
||||
<div class="compose-export-voice-col">
|
||||
<p class="compose-export-voice-title">Outgoing</p>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="I said" checked> I said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="You said"> You said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="We said"> We said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-out" class="compose-export-voice-out" value="They said"> They said</label>
|
||||
</div>
|
||||
<div class="compose-export-voice-col">
|
||||
<p class="compose-export-voice-title">Incoming</p>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="They said" checked> They said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="You said"> You said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="I said"> I said</label>
|
||||
<label class="radio is-size-7"><input type="radio" name="{{ panel_id }}-voice-in" class="compose-export-voice-in" value="We said"> We said</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="compose-export-fields">
|
||||
<summary>Fields</summary>
|
||||
<div class="compose-export-fields-grid">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="text" checked> Text</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="time" checked> Time</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="sender" checked> Sender</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_service"> Source</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_label"> Source Label</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="direction"> Direction</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="message_id"> Message ID</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="source_message_id"> Source Message ID</label>
|
||||
<label class="checkbox is-size-7"><input type="checkbox" class="compose-export-field" value="ts"> Timestamp</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" id="{{ panel_id }}-export-copy" class="button is-small is-link is-light">Copy</button>
|
||||
<button type="button" id="{{ panel_id }}-export-clear" class="button is-small is-light">Clear</button>
|
||||
</div>
|
||||
<div class="compose-export-presets">
|
||||
<span class="compose-export-help">Click one message for start, second for end. Third click starts a new range.</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="{{ panel_id }}-export-buffer"
|
||||
class="textarea is-small compose-export-buffer"
|
||||
readonly
|
||||
spellcheck="false"
|
||||
rows="10"
|
||||
aria-label="Export conversation history"></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{{ panel_id }}-thread"
|
||||
class="compose-thread"
|
||||
@@ -270,11 +343,13 @@
|
||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||
{% for msg in serialized_messages %}
|
||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}" data-author="{{ msg.author|default:''|escape }}" data-display-ts="{{ msg.display_ts|escape }}" data-source-service="{{ msg.source_service|default:''|escape }}" data-source-label="{{ msg.source_label|default:''|escape }}" data-source-message-id="{{ msg.source_message_id|default:''|escape }}" data-direction="{% if msg.outgoing %}outgoing{% else %}incoming{% endif %}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
|
||||
{% if msg.gap_fragments %}
|
||||
{% with gap=msg.gap_fragments.0 %}
|
||||
<p
|
||||
class="compose-latency-chip"
|
||||
data-lag-ms="{{ gap.lag_ms|default:0 }}"
|
||||
data-turn="{% if msg.outgoing %}outgoing{% else %}incoming{% endif %}"
|
||||
title="{{ gap.focus|default:'Opponent delay between turns.' }} · Latency {{ gap.lag|default:'-' }}{% if gap.calculation %} · How it is calculated: {{ gap.calculation }}{% endif %}{% if gap.psychology %} · Psychological interpretation: {{ gap.psychology }}{% endif %}">
|
||||
<span class="icon is-small"><i class="fa-regular fa-clock"></i></span>
|
||||
<span class="compose-latency-val">{{ gap.lag|default:"-" }}</span>
|
||||
@@ -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);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
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(),
|
||||
_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:
|
||||
|
||||
Reference in New Issue
Block a user