Implement more information displays

This commit is contained in:
2026-02-15 19:27:16 +00:00
parent 4cf75b9923
commit 1ebd565f44
13 changed files with 1421 additions and 271 deletions

View File

@@ -272,9 +272,29 @@
Home
</a>
{% if user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<a
class="navbar-link"
hx-get="{% url 'compose_contacts_dropdown' %}"
hx-target="#nav-compose-contacts"
hx-trigger="click once"
hx-swap="innerHTML">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span style="margin-left: 0.35rem;">Message</span>
</a>
<div class="navbar-dropdown" id="nav-compose-contacts">
<a class="navbar-item is-disabled">Load contacts</a>
</div>
</div>
<a class="navbar-item" href="{% url 'ai_workspace' %}">
AI
</a>
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
Search
</a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queue
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
@@ -350,28 +370,6 @@
</div>
<div class="navbar-end">
{% if user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<a
class="navbar-link"
hx-get="{% url 'compose_contacts_dropdown' %}"
hx-target="#nav-compose-contacts"
hx-trigger="click once"
hx-swap="innerHTML">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span style="margin-left: 0.35rem;">Message</span>
</a>
<div class="navbar-dropdown" id="nav-compose-contacts">
<a class="navbar-item is-disabled">Load contacts</a>
</div>
</div>
<a class="navbar-item" href="{% url 'ai_workspace' %}">
AI
</a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queue
</a>
{% endif %}
<div class="navbar-item">
<div class="buttons">
{% if not user.is_authenticated %}

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block content %}
<div class="columns is-multiline">
<div class="column is-12">
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
<ul>
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
<li class="is-active"><a aria-current="page">Information</a></li>
</ul>
</nav>
</div>
<div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors.</p>
<div class="tags has-addons" style="margin-top: 0.6rem;">
<a class="tag is-link is-light" href="{{ graphs_url }}">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>Insight Graphs</span>
</a>
<a class="tag is-dark" href="#">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
<span>Data View</span>
</a>
</div>
</div>
<div class="column is-5">
<div class="box">
<p class="heading">Commitment Directionality</p>
<p class="title is-5" style="margin-bottom: 0.35rem;">{{ directionality.direction_label }}</p>
<p><strong>Commit In:</strong> {{ directionality.commit_in|default:"-" }}</p>
<p><strong>Commit Out:</strong> {{ directionality.commit_out|default:"-" }}</p>
<p><strong>Delta:</strong> {{ directionality.delta|default:"-" }}</p>
<p><strong>Magnitude:</strong> {{ directionality.magnitude|default:"-" }}</p>
<p><strong>Confidence:</strong> {{ directionality.confidence|default:"-" }}</p>
<article class="message is-light" style="margin-top: 0.65rem;">
<div class="message-body">
{{ directionality.conclusion }}
</div>
</article>
</div>
<div class="buttons are-small">
<a class="button is-light" href="{{ graphs_url }}">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>All Graphs</span>
</a>
<a class="button is-light" href="{{ help_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
<span>Scoring Help</span>
</a>
</div>
</div>
<div class="column is-7">
<div class="box">
<p class="heading">Factor Inputs</p>
<div class="columns is-multiline" style="margin: 0 -0.2rem;">
{% for factor in directionality.factors %}
<div class="column is-12-mobile is-6-tablet" style="padding: 0.2rem;">
<article class="box" style="margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">
<span class="icon is-small"><i class="{{ factor.icon }}"></i></span>
<span>{{ factor.title }}</span>
</p>
<p class="is-size-7"><strong>Weight:</strong> {{ factor.weight }}</p>
<p class="is-size-7"><strong>Value:</strong> {{ factor.value|default:"-" }}</p>
<p style="margin-top: 0.35rem;">
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=factor.slug %}">
View Metric
</a>
</p>
</article>
</div>
{% endfor %}
</div>
</div>
<div class="box">
<p class="heading">Linked Graphs</p>
<div class="tags">
{% for ref in directionality.graph_refs %}
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=ref.slug %}">
{{ ref.title }}: {{ ref.value|default:"-" }}
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -38,6 +38,10 @@
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>All Graphs</span>
</a>
<a class="button is-light" href="{{ information_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
<span>Information</span>
</a>
<a class="button is-light" href="{{ help_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
<span>Scoring Help</span>

View File

@@ -17,6 +17,10 @@
Historical metrics for workspace {{ workspace_conversation.id }}
</p>
<div class="buttons are-small" style="margin-top: 0.6rem;">
<a class="button is-light" href="{{ information_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
<span>Information</span>
</a>
<a class="button is-light" href="{{ help_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
<span>Scoring Help</span>

View File

@@ -17,6 +17,16 @@
Combined explanation for each metric collection group and what it can
imply in relationship dynamics.
</p>
<div class="buttons are-small" style="margin-top: 0.6rem;">
<a class="button is-light" href="{{ graphs_url }}">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>Insight Graphs</span>
</a>
<a class="button is-light" href="{{ information_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
<span>Information</span>
</a>
</div>
</div>
{% for group_key, group in groups.items %}

View File

@@ -62,7 +62,7 @@
data-index="{{ forloop.counter0 }}"
onclick="giaWorkspaceUseDraft('{{ person.id }}', '{{ operation }}', {{ forloop.counter0 }}); return false;"
style="height: 100%; padding: 0.6rem; border-radius: 9px; border: 1px solid rgba(0, 0, 0, 0.16); background: #fff; cursor: pointer; transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;">
<p class="is-size-7 has-text-weight-semibold is-flex is-align-items-center" style="margin-bottom: 0.35rem; gap: 0.35rem;">
<p class="draft-tone-line">
{% with tone=option.label|default:""|lower %}
{% if tone == "soft" %}
<span class="icon is-small has-text-success"><i class="fa-solid fa-leaf"></i></span>
@@ -301,6 +301,20 @@
box-shadow: inset 0 0 0 1px rgba(54, 54, 54, 0.18);
background-color: rgba(54, 54, 54, 0.06) !important;
}
.draft-tone-line {
margin-bottom: 0.26rem;
color: #6f7680;
font-size: 0.73rem;
font-weight: 600;
letter-spacing: 0.01em;
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.htmx-indicator {
display: none;
}

View File

@@ -0,0 +1,34 @@
<div id="ai-person-timeline-{{ person.id }}">
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<p class="is-size-7 has-text-weight-semibold">Timeline</p>
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
<p class="is-size-7">Showing last {{ limit }} messages.</p>
</div>
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
{% if message_rows %}
{% for row in message_rows %}
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
<div class="media-content">
<div
class="content"
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
<div
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
<p class="is-size-7">
{{ row.ts_label }}
{% if row.message.custom_author %}
| {{ row.message.custom_author }}
{% endif %}
</p>
</div>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="has-text-grey">No messages found for this contact.</p>
{% endif %}
</div>
</div>

View File

@@ -9,7 +9,6 @@
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
<p class="is-size-7">Showing last {{ limit }} messages.</p>
<div class="tags" style="margin-top: 0.35rem;">
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='platform' %}">Platform {{ workspace_conversation.platform_type|title }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='thread' %}">Thread {{ workspace_conversation.platform_thread_id|default:"-" }}</a>
@@ -126,72 +125,14 @@
</div>
<div id="ai-stage-{{ person.id }}" style="min-height: 7rem;">
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-warning is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
<span>Plan</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-link is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
<span>Summary</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane">
<button
type="button"
class="button is-primary is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Draft</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-info is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-wave-square"></i></span>
<span>Patterns</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;"></div>
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;"></div>
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane"></div>
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;"></div>
</div>
</div>
</div>
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
{% if message_rows %}
{% for row in message_rows %}
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
<div class="media-content">
<div
class="content"
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
<div
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
<p class="is-size-7">
{{ row.ts_label }}
{% if row.message.custom_author %}
| {{ row.message.custom_author }}
{% endif %}
</p>
</div>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="has-text-grey">No messages found for this contact.</p>
{% endif %}
</div>
</div>
<style>
@@ -266,88 +207,6 @@
}
}
function formatUtcLabel(tsMs) {
const ts = Number(tsMs || 0);
if (!ts) {
return "";
}
const dt = new Date(ts);
function pad(value) {
return String(value).padStart(2, "0");
}
return (
dt.getUTCFullYear()
+ "-" + pad(dt.getUTCMonth() + 1)
+ "-" + pad(dt.getUTCDate())
+ " " + pad(dt.getUTCHours())
+ ":" + pad(dt.getUTCMinutes())
+ " UTC"
);
}
function appendOutgoingMessage(tsMs, text, author) {
const host = document.getElementById("ai-message-list-" + personId);
if (!host) {
return;
}
const noMessages = host.querySelector("p.has-text-grey");
if (noMessages) {
noMessages.remove();
}
const article = document.createElement("article");
article.className = "media ai-message-row";
article.dataset.ts = String(Number(tsMs || Date.now()));
article.style.marginBottom = "0.75rem";
const mediaContent = document.createElement("div");
mediaContent.className = "media-content";
const contentWrap = document.createElement("div");
contentWrap.className = "content";
contentWrap.style.marginLeft = "15%";
contentWrap.style.marginRight = "0";
const bubble = document.createElement("div");
bubble.style.marginBottom = "0.25rem";
bubble.style.padding = "0.6rem";
bubble.style.borderRadius = "6px";
bubble.style.border = "1px solid rgba(0, 0, 0, 0.15)";
bubble.style.background = "#f0f7ff";
bubble.style.boxShadow = "none";
const bodyP = document.createElement("p");
bodyP.style.whiteSpace = "pre-wrap";
bodyP.style.marginBottom = "0.35rem";
bodyP.textContent = text || "(no text)";
const metaP = document.createElement("p");
metaP.className = "is-size-7";
metaP.textContent = formatUtcLabel(tsMs);
if (author) {
metaP.textContent += " | " + author;
}
bubble.appendChild(bodyP);
bubble.appendChild(metaP);
contentWrap.appendChild(bubble);
mediaContent.appendChild(contentWrap);
article.appendChild(mediaContent);
host.appendChild(article);
const maxRows = Math.max(5, Math.min(parseInt(widget.dataset.limit || "20", 10) || 20, 200));
const rows = host.querySelectorAll(".ai-message-row");
if (rows.length > maxRows) {
const removeCount = rows.length - maxRows;
for (let i = 0; i < removeCount; i += 1) {
if (rows[i] && rows[i].parentNode) {
rows[i].parentNode.removeChild(rows[i]);
}
}
}
host.scrollTop = host.scrollHeight;
}
function getCacheEntry(operation) {
const key = cacheKey(operation);
const raw = window.giaWorkspaceCache[key];
@@ -725,25 +584,6 @@
};
}
window.giaWorkspaceMessageListeners = window.giaWorkspaceMessageListeners || {};
const existingListener = window.giaWorkspaceMessageListeners[personId];
if (existingListener) {
document.body.removeEventListener("gia-message-sent", existingListener);
}
const messageSentListener = function(evt) {
const detail = (evt && evt.detail) ? evt.detail : {};
if (!detail || String(detail.person_id || "") !== personId) {
return;
}
appendOutgoingMessage(
Number(detail.ts || Date.now()),
String(detail.text || ""),
String(detail.author || "BOT")
);
};
document.body.addEventListener("gia-message-sent", messageSentListener);
window.giaWorkspaceMessageListeners[personId] = messageSentListener;
window.giaWorkspaceRun(personId, "artifacts", false);
})();
</script>

View File

@@ -26,6 +26,10 @@
<span class="icon is-small"><i class="fa-solid fa-handshake"></i></span>
<span>Engage</span>
</button>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="quick_insights">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>Quick Insights</span>
</button>
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>AI Workspace</span>
@@ -57,6 +61,15 @@
</div>
<div class="compose-ai-content"></div>
</div>
<div class="compose-ai-card" data-kind="quick_insights">
<p class="compose-ai-title">Quick Insights</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
</div>
<div class="compose-ai-card" data-kind="engage">
<p class="compose-ai-title">Quick Engage (Shared Framing)</p>
<div class="compose-engage-source-row">
@@ -103,6 +116,7 @@
data-ws-url="{{ compose_ws_url }}"
data-drafts-url="{{ compose_drafts_url }}"
data-summary-url="{{ compose_summary_url }}"
data-quick-insights-url="{{ compose_quick_insights_url }}"
data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}">
{% for msg in serialized_messages %}
@@ -381,19 +395,25 @@
margin-bottom: 0.45rem;
border-radius: 8px;
white-space: normal;
word-break: break-word;
height: auto;
justify-content: flex-start;
align-items: flex-start;
line-height: 1.35;
padding: 0.58rem 0.62rem;
}
#{{ panel_id }} .compose-draft-option strong {
display: inline;
margin-right: 0.2rem;
white-space: normal;
#{{ panel_id }} .compose-draft-tone {
margin: 0 0 0.2rem 0;
color: #6e7782;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#{{ panel_id }} .compose-draft-option span {
white-space: normal;
#{{ panel_id }} .compose-draft-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
@@ -406,11 +426,12 @@
#{{ panel_id }} .js-ai-trigger {
position: relative;
overflow: visible;
}
#{{ panel_id }} .js-ai-trigger.is-expanded {
background: #eef6ff;
border-color: #8bb2e6;
}
#{{ panel_id }} .js-ai-trigger.is-expanded {
font-weight: 600;
}
#{{ panel_id }} .js-ai-trigger.is-expanded::after {
content: "";
position: absolute;
@@ -424,6 +445,83 @@
border-top: 6px solid #8bb2e6;
pointer-events: none;
}
#{{ panel_id }} .compose-qi-head {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.32rem;
margin-bottom: 0.5rem;
}
#{{ panel_id }} .compose-qi-chip {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
padding: 0.35rem 0.42rem;
background: #fff;
}
#{{ panel_id }} .compose-qi-chip p {
margin: 0;
line-height: 1.25;
}
#{{ panel_id }} .compose-qi-chip .k {
color: #657283;
font-size: 0.67rem;
}
#{{ panel_id }} .compose-qi-chip .v {
font-size: 0.78rem;
font-weight: 600;
}
#{{ panel_id }} .compose-qi-list {
display: grid;
gap: 0.36rem;
}
#{{ panel_id }} .compose-qi-row {
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: #fff;
padding: 0.42rem 0.46rem;
}
#{{ panel_id }} .compose-qi-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
#{{ panel_id }} .compose-qi-row-label {
display: inline-flex;
align-items: center;
gap: 0.32rem;
min-width: 0;
font-size: 0.74rem;
font-weight: 600;
}
#{{ panel_id }} .compose-qi-row-meta {
display: inline-flex;
align-items: center;
gap: 0.44rem;
font-size: 0.68rem;
color: #657283;
}
#{{ panel_id }} .compose-qi-row-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
#{{ panel_id }} .compose-qi-value {
font-size: 0.9rem;
font-weight: 700;
color: #202835;
}
#{{ panel_id }} .compose-qi-docs {
margin: 0.5rem 0 0;
padding-left: 1.1rem;
color: #657283;
font-size: 0.71rem;
}
#{{ panel_id }} .compose-qi-docs li {
margin-bottom: 0.2rem;
}
#{{ panel_id }} .compose-image-fallback.is-hidden {
display: none;
}
@@ -515,6 +613,9 @@
if (previousState && previousState.eventHandler) {
document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
}
if (previousState && previousState.sendResultHandler) {
document.body.removeEventListener("composeSendResult", previousState.sendResultHandler);
}
if (previousState && previousState.docClickHandler) {
document.removeEventListener("mousedown", previousState.docClickHandler);
}
@@ -554,6 +655,37 @@
}
};
const extractUrlCandidates = function (value) {
const raw = String(value || "");
const matches = raw.match(/https?:\/\/[^\s<>'"\\]+/g) || [];
const seen = new Set();
const output = [];
matches.forEach(function (item) {
const cleaned = String(item).trim().replace(/[.,);:!?\"']+$/g, "");
if (!cleaned || seen.has(cleaned)) {
return;
}
seen.add(cleaned);
output.push(cleaned);
});
return output;
};
const appendImageCandidates = function (bubble, candidates) {
(candidates || []).forEach(function (candidateUrl) {
const figure = document.createElement("figure");
figure.className = "compose-media";
const img = document.createElement("img");
img.className = "compose-image";
img.src = String(candidateUrl);
img.alt = "Attachment";
img.loading = "lazy";
img.decoding = "async";
figure.appendChild(img);
bubble.insertBefore(figure, bubble.firstChild);
});
};
const wireImageFallbacks = function (rootNode) {
const scope = rootNode || thread;
if (!scope) {
@@ -590,6 +722,28 @@
});
};
const hydrateBodyUrlsAsImages = function (rootNode) {
const scope = rootNode || thread;
if (!scope) {
return;
}
scope.querySelectorAll(".compose-bubble").forEach(function (bubble) {
if (bubble.querySelector(".compose-image")) {
return;
}
const body = bubble.querySelector(".compose-body");
if (!body) {
return;
}
const candidates = extractUrlCandidates(body.textContent || "");
if (!candidates.length) {
return;
}
appendImageCandidates(bubble, candidates);
});
wireImageFallbacks(scope);
};
const appendBubble = function (msg) {
const row = document.createElement("div");
const outgoing = !!msg.outgoing;
@@ -599,21 +753,13 @@
const bubble = document.createElement("article");
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
const imageCandidates = Array.isArray(msg.image_urls) && msg.image_urls.length
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
? msg.image_urls
: (msg.image_url ? [msg.image_url] : []);
imageCandidates.forEach(function (candidateUrl) {
const figure = document.createElement("figure");
figure.className = "compose-media";
const img = document.createElement("img");
img.className = "compose-image";
img.src = String(candidateUrl);
img.alt = "Attachment";
img.loading = "lazy";
img.decoding = "async";
figure.appendChild(img);
bubble.appendChild(figure);
});
const imageCandidates = imageCandidatesFromPayload.length
? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || "");
appendImageCandidates(bubble, imageCandidates);
if (!msg.hide_text) {
const body = document.createElement("p");
@@ -908,11 +1054,13 @@
const engageButton = document.createElement("button");
engageButton.type = "button";
engageButton.className = "button is-link is-light compose-draft-option";
const engageStrong = document.createElement("strong");
engageStrong.textContent = "Custom Engage: ";
const engageText = document.createElement("span");
const engageTone = document.createElement("p");
engageTone.className = "compose-draft-tone";
engageTone.textContent = "Custom Engage";
const engageText = document.createElement("p");
engageText.className = "compose-draft-text";
engageText.textContent = "Choose a source or write your own engagement text.";
engageButton.appendChild(engageStrong);
engageButton.appendChild(engageTone);
engageButton.appendChild(engageText);
engageButton.addEventListener("click", function () {
openEngage("custom");
@@ -922,12 +1070,14 @@
const button = document.createElement("button");
button.type = "button";
button.className = "button is-light compose-draft-option";
const strong = document.createElement("strong");
strong.textContent = String(item.label || "Option") + ": ";
const span = document.createElement("span");
span.textContent = String(item.text || "");
button.appendChild(strong);
button.appendChild(span);
const tone = document.createElement("p");
tone.className = "compose-draft-tone";
tone.textContent = String(item.label || "Option");
const body = document.createElement("p");
body.className = "compose-draft-text";
body.textContent = String(item.text || "");
button.appendChild(tone);
button.appendChild(body);
button.addEventListener("click", function () {
textarea.value = String(item.text || "");
autosize();
@@ -967,6 +1117,110 @@
}
};
const loadQuickInsights = async function () {
const card = showCard("quick_insights");
if (!card) {
return;
}
setCardLoading(card, true);
try {
const response = await fetch(
thread.dataset.quickInsightsUrl + "?" + queryParams().toString(),
{
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
}
);
const payload = await response.json();
setCardLoading(card, false);
const container = card.querySelector(".compose-ai-content");
if (!payload.ok) {
container.textContent = payload.error || "Failed to load quick insights.";
return;
}
const summary = payload.summary || {};
const rows = Array.isArray(payload.rows) ? payload.rows : [];
const docs = Array.isArray(payload.docs) ? payload.docs : [];
container.innerHTML = "";
const head = document.createElement("div");
head.className = "compose-qi-head";
[
["Platform", summary.platform || "-"],
["State", summary.state || "-"],
["Data Points", String(summary.snapshot_count || 0)],
["Thread", summary.thread || "-"],
].forEach(function (pair) {
const chip = document.createElement("div");
chip.className = "compose-qi-chip";
chip.innerHTML = (
'<p class="k">' + pair[0] + "</p>"
+ '<p class="v">' + pair[1] + "</p>"
);
head.appendChild(chip);
});
container.appendChild(head);
if (!rows.length) {
const none = document.createElement("p");
none.className = "is-size-7 has-text-grey";
none.textContent = "No metric rows available yet.";
container.appendChild(none);
} else {
const list = document.createElement("div");
list.className = "compose-qi-list";
rows.forEach(function (row) {
const node = document.createElement("article");
node.className = "compose-qi-row";
node.innerHTML = (
'<div class="compose-qi-row-head">'
+ '<p class="compose-qi-row-label"><span class="icon is-small"><i class="'
+ String(row.icon || "fa-solid fa-square") + '"></i></span><span>'
+ String(row.label || "") + "</span></p>"
+ '<p class="compose-qi-row-meta"><span>' + String(row.point_count || 0)
+ ' points</span><span class="' + String((row.trend || {}).class_name || "")
+ '"><span class="icon is-small"><i class="' + String((row.trend || {}).icon || "")
+ '"></i></span> ' + String(row.delta_label || "n/a")
+ "</span></p></div>"
+ '<div class="compose-qi-row-body">'
+ '<p class="compose-qi-value">' + String(row.display_value || "-") + "</p>"
+ '<p class="' + String(((row.emotion || {}).class_name) || "")
+ '" style="margin:0; font-size:0.72rem;">'
+ '<span class="icon is-small"><i class="' + String(((row.emotion || {}).icon) || "")
+ '"></i></span> ' + String(((row.emotion || {}).label) || "Unknown")
+ "</p></div>"
);
list.appendChild(node);
});
container.appendChild(list);
}
if (docs.length) {
const docsList = document.createElement("ul");
docsList.className = "compose-qi-docs";
docs.forEach(function (item) {
const li = document.createElement("li");
li.textContent = String(item || "");
docsList.appendChild(li);
});
container.appendChild(docsList);
}
const openBtn = document.createElement("a");
openBtn.className = "button is-light is-small is-rounded";
openBtn.href = "{{ ai_workspace_url }}";
openBtn.innerHTML = (
'<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>'
+ "<span>Open Minimal AI Workspace</span>"
);
openBtn.style.marginTop = "0.45rem";
container.appendChild(openBtn);
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent =
"Failed to load quick insights.";
}
};
const loadEngage = async function (card, preferredSource) {
card = card || showCard("engage");
if (!card) {
@@ -1157,6 +1411,8 @@
loadDrafts();
} else if (kind === "summary") {
loadSummary();
} else if (kind === "quick_insights") {
loadQuickInsights();
} else if (kind === "engage") {
openEngage("auto");
}
@@ -1184,7 +1440,6 @@
});
}
document.addEventListener("mousedown", panelState.docClickHandler);
textarea.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
@@ -1224,7 +1479,7 @@
};
document.body.addEventListener("composeSendResult", panelState.sendResultHandler);
wireImageFallbacks(thread);
hydrateBodyUrlsAsImages(thread);
scrollToBottom(true);
setupWebSocket();
panelState.timer = setInterval(function () {