Renovate mitigation panels and messages
This commit is contained in:
10
app/urls.py
10
app/urls.py
@@ -119,11 +119,21 @@ urlpatterns = [
|
|||||||
compose.ComposePage.as_view(),
|
compose.ComposePage.as_view(),
|
||||||
name="compose_page",
|
name="compose_page",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/workspace/",
|
||||||
|
compose.ComposeWorkspace.as_view(),
|
||||||
|
name="compose_workspace",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/widget/",
|
"compose/widget/",
|
||||||
compose.ComposeWidget.as_view(),
|
compose.ComposeWidget.as_view(),
|
||||||
name="compose_widget",
|
name="compose_widget",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/workspace/widget/contacts/",
|
||||||
|
compose.ComposeWorkspaceContactsWidget.as_view(),
|
||||||
|
name="compose_workspace_contacts_widget",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/send/",
|
"compose/send/",
|
||||||
compose.ComposeSend.as_view(),
|
compose.ComposeSend.as_view(),
|
||||||
|
|||||||
@@ -60,6 +60,41 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composeLink = document.getElementById('nav-compose-link');
|
||||||
|
const composeDropdown = document.getElementById('nav-compose-contacts');
|
||||||
|
let composePreviewLoaded = false;
|
||||||
|
let composePreviewLoading = false;
|
||||||
|
if (composeLink && composeDropdown) {
|
||||||
|
composeLink.addEventListener('mouseenter', () => {
|
||||||
|
const previewUrl = composeLink.dataset.previewUrl || '';
|
||||||
|
if (!previewUrl || composePreviewLoaded || composePreviewLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
composePreviewLoading = true;
|
||||||
|
fetch(previewUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'HX-Request': 'true' },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed contacts preview fetch.');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then((html) => {
|
||||||
|
composeDropdown.innerHTML = html;
|
||||||
|
composePreviewLoaded = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
composePreviewLoaded = false;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
composePreviewLoading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
@@ -274,16 +309,25 @@
|
|||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
<a
|
<a
|
||||||
|
id="nav-compose-link"
|
||||||
class="navbar-link"
|
class="navbar-link"
|
||||||
hx-get="{% url 'compose_contacts_dropdown' %}"
|
data-preview-url="{% url 'compose_contacts_dropdown' %}"
|
||||||
|
data-full-url="{% url 'compose_contacts_dropdown' %}?all=1"
|
||||||
|
hx-get="{% url 'compose_contacts_dropdown' %}?all=1"
|
||||||
hx-target="#nav-compose-contacts"
|
hx-target="#nav-compose-contacts"
|
||||||
hx-trigger="click once"
|
hx-trigger="click"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
<span style="margin-left: 0.35rem;">Message</span>
|
<span style="margin-left: 0.35rem;">Message</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="navbar-dropdown" id="nav-compose-contacts">
|
<div class="navbar-dropdown" id="nav-compose-contacts">
|
||||||
<a class="navbar-item is-disabled">Load contacts</a>
|
<a
|
||||||
|
class="navbar-item"
|
||||||
|
hx-get="{% url 'compose_contacts_dropdown' %}?all=1"
|
||||||
|
hx-target="#nav-compose-contacts"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Fetch Contacts
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||||
|
|||||||
@@ -18,6 +18,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-5">
|
<div class="column is-5">
|
||||||
|
<div class="box">
|
||||||
|
<p class="heading">Conversation Overview</p>
|
||||||
|
{% if overview_rows %}
|
||||||
|
{% for row in overview_rows %}
|
||||||
|
<article class="message is-light" style="margin-bottom: 0.45rem;">
|
||||||
|
<div class="message-body" style="padding: 0.45rem 0.55rem;">
|
||||||
|
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.12rem;">
|
||||||
|
{{ row.group_title }}
|
||||||
|
</p>
|
||||||
|
<p style="margin-bottom: 0.3rem;">
|
||||||
|
<strong>{{ row.title }}:</strong> {{ row.value|default:"-" }}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
class="tag is-light"
|
||||||
|
href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=row.slug %}">
|
||||||
|
View Detail
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="is-size-7 has-text-grey">No conversation metadata available yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<p class="heading">Commitment Directionality</p>
|
<p class="heading">Commitment Directionality</p>
|
||||||
<p class="title is-5" style="margin-bottom: 0.35rem;">{{ directionality.direction_label }}</p>
|
<p class="title is-5" style="margin-bottom: 0.35rem;">{{ directionality.direction_label }}</p>
|
||||||
|
|||||||
20
core/templates/pages/compose-workspace.html
Normal file
20
core/templates/pages/compose-workspace.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "index.html" %}
|
||||||
|
|
||||||
|
{% block load_widgets %}
|
||||||
|
<div
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ contacts_widget_url }}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="afterend"
|
||||||
|
style="display: none;"></div>
|
||||||
|
{% if initial_widget_url %}
|
||||||
|
<div
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ initial_widget_url }}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-trigger="load delay:250ms"
|
||||||
|
hx-swap="afterend"
|
||||||
|
style="display: none;"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<div style="margin-bottom: 0.5rem;">
|
<div
|
||||||
|
id="ai-result-links-{{ person.id }}-{{ operation }}-{{ ai_result_id|default:'new' }}"
|
||||||
|
style="margin-bottom: 0.5rem;">
|
||||||
<div class="tags has-addons" style="display: inline-flex; margin-bottom: 0.4rem;">
|
<div class="tags has-addons" style="display: inline-flex; margin-bottom: 0.4rem;">
|
||||||
<span class="tag is-dark">
|
<span class="tag is-dark">
|
||||||
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
|
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
|
||||||
@@ -45,6 +47,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if operation == "artifacts" %}
|
{% if operation == "artifacts" %}
|
||||||
|
<details
|
||||||
|
id="ai-mitigation-collapsible-{{ person.id }}"
|
||||||
|
class="ai-mitigation-collapsible"
|
||||||
|
open
|
||||||
|
style="margin-top: 0.35rem;">
|
||||||
|
<summary class="ai-mitigation-summary">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-shield-heart"></i></span>
|
||||||
|
<span>
|
||||||
|
Mitigation Protocol
|
||||||
|
{% if latest_plan %}
|
||||||
|
· {{ latest_plan.status|title }}
|
||||||
|
{% else %}
|
||||||
|
· Not Created
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="ai-mitigation-collapsible-body">
|
||||||
{% if latest_plan %}
|
{% if latest_plan %}
|
||||||
{% include "partials/ai-workspace-mitigation-panel.html" with person=person plan=latest_plan rules=latest_plan_rules games=latest_plan_games corrections=latest_plan_corrections fundamentals_text=latest_plan.fundamental_items|join:"\n" mitigation_messages=latest_plan_messages latest_export=latest_plan_export notice_message=mitigation_notice_message notice_level=mitigation_notice_level auto_settings=latest_auto_settings active_tab="plan_board" %}
|
{% include "partials/ai-workspace-mitigation-panel.html" with person=person plan=latest_plan rules=latest_plan_rules games=latest_plan_games corrections=latest_plan_corrections fundamentals_text=latest_plan.fundamental_items|join:"\n" mitigation_messages=latest_plan_messages latest_export=latest_plan_export notice_message=mitigation_notice_message notice_level=mitigation_notice_level auto_settings=latest_auto_settings active_tab="plan_board" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -52,6 +71,8 @@
|
|||||||
<p class="is-size-7 has-text-grey">No mitigation plan yet. Use the Patterns tab to generate one.</p>
|
<p class="is-size-7 has-text-grey">No mitigation plan yet. Use the Patterns tab to generate one.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
{% elif operation == "draft_reply" and draft_replies %}
|
{% elif operation == "draft_reply" and draft_replies %}
|
||||||
<div id="draft-host-{{ person.id }}-{{ operation }}" data-selected="0">
|
<div id="draft-host-{{ person.id }}-{{ operation }}" data-selected="0">
|
||||||
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
@@ -177,24 +198,50 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if interaction_signals %}
|
{% if interaction_signals %}
|
||||||
<article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
<article
|
||||||
|
id="ai-signals-{{ person.id }}-{{ operation }}"
|
||||||
|
class="box"
|
||||||
|
style="padding: 0.55rem; margin-top: 0.55rem; 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.35rem;">Interaction Signals</p>
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Interaction Signals</p>
|
||||||
|
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.35rem;">
|
||||||
|
Click a signal to highlight related memory proposals and evidence citations.
|
||||||
|
</p>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{% for signal in interaction_signals %}
|
{% for signal in interaction_signals %}
|
||||||
<span class="tag is-light">{{ signal.label }} ({{ signal.valence }})</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tag is-light js-ai-signal-tag ai-linkable"
|
||||||
|
data-signal-key="{{ signal.signal_key|default:signal.label }}"
|
||||||
|
data-citation-ids="{{ signal.message_event_ids|join:',' }}"
|
||||||
|
title="{{ signal.meaning|default:'Linked conversational signal from this AI run.' }}">
|
||||||
|
{{ signal.display_label|default:signal.label|title }} ({{ signal.valence|title }})
|
||||||
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if memory_proposals %}
|
{% if memory_proposals %}
|
||||||
<section style="margin-top: 0.55rem;">
|
<section id="ai-memory-proposals-{{ person.id }}-{{ operation }}" style="margin-top: 0.55rem;">
|
||||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Memory Proposals</p>
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Memory Proposals</p>
|
||||||
|
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.35rem;">
|
||||||
|
Candidate memory extracts grouped by theme. Click a group to reveal linked signals and citations.
|
||||||
|
</p>
|
||||||
{% if memory_proposal_groups %}
|
{% if memory_proposal_groups %}
|
||||||
<div class="columns is-multiline" style="margin: 0 -0.25rem;">
|
<div class="columns is-multiline" style="margin: 0 -0.25rem;">
|
||||||
{% for group in memory_proposal_groups %}
|
{% for group in memory_proposal_groups %}
|
||||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.25rem;">
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.25rem;">
|
||||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">{{ group.title }}</p>
|
<div
|
||||||
|
class="js-ai-memory-group ai-linkable"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
data-memory-key="{{ group.key|default:group.title }}"
|
||||||
|
data-signal-keys="{{ group.signal_keys|join:',' }}"
|
||||||
|
title="Highlight related interaction signals and citations."
|
||||||
|
style="padding: 0.1rem 0.15rem; border-radius: 8px;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">
|
||||||
|
{{ group.title }}
|
||||||
|
</p>
|
||||||
<ul style="margin: 0 0 0.25rem 1.1rem;">
|
<ul style="margin: 0 0 0.25rem 1.1rem;">
|
||||||
{% for proposal in group.items %}
|
{% for proposal in group.items %}
|
||||||
<li class="is-size-7" style="margin-bottom: 0.22rem; white-space: pre-wrap;">
|
<li class="is-size-7" style="margin-bottom: 0.22rem; white-space: pre-wrap;">
|
||||||
@@ -203,6 +250,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -216,12 +264,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if citations %}
|
{% if citations %}
|
||||||
<article class="box" style="padding: 0.55rem; margin-top: 0.55rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
<article
|
||||||
|
id="ai-citations-{{ person.id }}-{{ operation }}"
|
||||||
|
class="box"
|
||||||
|
style="padding: 0.55rem; margin-top: 0.55rem; 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.35rem;">Citations</p>
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Citations</p>
|
||||||
|
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.35rem;">
|
||||||
|
These are source messages used as evidence for this AI result.
|
||||||
|
</p>
|
||||||
{% if citation_rows %}
|
{% if citation_rows %}
|
||||||
<div class="content is-small" style="margin-bottom: 0;">
|
<div class="content is-small" style="margin-bottom: 0;">
|
||||||
{% for row in citation_rows %}
|
{% for row in citation_rows %}
|
||||||
<p class="is-size-7" style="margin-bottom: 0.3rem;">
|
<p class="is-size-7 js-ai-citation-row" data-citation-id="{{ row.id }}" style="margin-bottom: 0.3rem;">
|
||||||
<span class="tag is-light">{{ row.source_system|default:"event" }}</span>
|
<span class="tag is-light">{{ row.source_system|default:"event" }}</span>
|
||||||
{% if row.ts_label %}
|
{% if row.ts_label %}
|
||||||
<span class="has-text-grey">{{ row.ts_label }}</span>
|
<span class="has-text-grey">{{ row.ts_label }}</span>
|
||||||
@@ -283,16 +337,231 @@
|
|||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
<div class="box" style="padding: 0.65rem; margin-top: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
||||||
<p class="is-size-7 has-text-grey">
|
<p class="is-size-7 has-text-grey">
|
||||||
Plan editing is consolidated in the <strong>Plan</strong> tab.
|
Plan editing is consolidated in the <strong>Plan</strong> tab.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="buttons are-small" style="margin-top: 0.4rem; margin-bottom: 0;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-light"
|
||||||
|
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'plan_board', false); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-shield-heart"></i></span>
|
||||||
|
<span>Open Plan Tab</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const root = document.getElementById(
|
||||||
|
"ai-result-links-{{ person.id }}-{{ operation }}-{{ ai_result_id|default:'new' }}"
|
||||||
|
);
|
||||||
|
if (!root || root.dataset.linkedBound === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.dataset.linkedBound = "1";
|
||||||
|
|
||||||
|
const signalTags = Array.from(root.querySelectorAll(".js-ai-signal-tag"));
|
||||||
|
const memoryGroups = Array.from(root.querySelectorAll(".js-ai-memory-group"));
|
||||||
|
const citationRows = Array.from(root.querySelectorAll(".js-ai-citation-row"));
|
||||||
|
const citationsBox = root.querySelector(
|
||||||
|
"#ai-citations-{{ person.id }}-{{ operation }}"
|
||||||
|
);
|
||||||
|
const memorySection = root.querySelector(
|
||||||
|
"#ai-memory-proposals-{{ person.id }}-{{ operation }}"
|
||||||
|
);
|
||||||
|
const signalsSection = root.querySelector(
|
||||||
|
"#ai-signals-{{ person.id }}-{{ operation }}"
|
||||||
|
);
|
||||||
|
|
||||||
|
const citationById = new Map();
|
||||||
|
citationRows.forEach(function (row) {
|
||||||
|
const key = String(row.dataset.citationId || "").trim();
|
||||||
|
if (key) {
|
||||||
|
citationById.set(key, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalize = function (value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.replace("open_loops", "open_loop");
|
||||||
|
};
|
||||||
|
|
||||||
|
const listFromCsv = function (value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map(function (item) {
|
||||||
|
return item.trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flashNode = function (node) {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.classList.remove("ai-link-flash");
|
||||||
|
void node.offsetWidth;
|
||||||
|
node.classList.add("ai-link-flash");
|
||||||
|
window.setTimeout(function () {
|
||||||
|
node.classList.remove("ai-link-flash");
|
||||||
|
}, 900);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flashCitationsByIds = function (ids) {
|
||||||
|
let matched = false;
|
||||||
|
ids.forEach(function (id) {
|
||||||
|
const node = citationById.get(String(id || "").trim());
|
||||||
|
if (node) {
|
||||||
|
matched = true;
|
||||||
|
flashNode(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (matched) {
|
||||||
|
flashNode(citationsBox);
|
||||||
|
} else {
|
||||||
|
flashNode(citationsBox);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signalKeyFromNode = function (node) {
|
||||||
|
return normalize(node.dataset.signalKey || node.textContent || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignalClick = function (sourceTag) {
|
||||||
|
const key = signalKeyFromNode(sourceTag);
|
||||||
|
const ids = listFromCsv(sourceTag.dataset.citationIds || "");
|
||||||
|
|
||||||
|
signalTags.forEach(function (tag) {
|
||||||
|
if (signalKeyFromNode(tag) === key) {
|
||||||
|
flashNode(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let matchedGroup = false;
|
||||||
|
memoryGroups.forEach(function (group) {
|
||||||
|
const memoryKey = normalize(group.dataset.memoryKey || "");
|
||||||
|
const signalKeys = listFromCsv(group.dataset.signalKeys || "").map(normalize);
|
||||||
|
const linked = signalKeys.includes(key) || memoryKey === key;
|
||||||
|
if (linked) {
|
||||||
|
matchedGroup = true;
|
||||||
|
flashNode(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!matchedGroup) {
|
||||||
|
flashNode(memorySection);
|
||||||
|
}
|
||||||
|
|
||||||
|
flashCitationsByIds(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemoryClick = function (group) {
|
||||||
|
const signalKeys = listFromCsv(group.dataset.signalKeys || "").map(normalize);
|
||||||
|
const memoryKey = normalize(group.dataset.memoryKey || "");
|
||||||
|
const allKeys = Array.from(new Set(signalKeys.concat([memoryKey])));
|
||||||
|
|
||||||
|
flashNode(group);
|
||||||
|
|
||||||
|
let matchedSignal = false;
|
||||||
|
const ids = [];
|
||||||
|
signalTags.forEach(function (tag) {
|
||||||
|
const key = signalKeyFromNode(tag);
|
||||||
|
if (allKeys.includes(key)) {
|
||||||
|
matchedSignal = true;
|
||||||
|
flashNode(tag);
|
||||||
|
ids.push.apply(ids, listFromCsv(tag.dataset.citationIds || ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!matchedSignal) {
|
||||||
|
flashNode(signalsSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
flashCitationsByIds(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
signalTags.forEach(function (tag) {
|
||||||
|
tag.addEventListener("click", function () {
|
||||||
|
handleSignalClick(tag);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
memoryGroups.forEach(function (group) {
|
||||||
|
group.addEventListener("click", function () {
|
||||||
|
handleMemoryClick(group);
|
||||||
|
});
|
||||||
|
group.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleMemoryClick(group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.ai-mitigation-collapsible {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
}
|
||||||
|
.ai-mitigation-summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #35475f;
|
||||||
|
}
|
||||||
|
.ai-mitigation-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ai-mitigation-collapsible .ai-mitigation-collapsible-body {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.js-ai-signal-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.ai-linkable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.ai-linkable:hover {
|
||||||
|
background: rgba(54, 114, 206, 0.06);
|
||||||
|
}
|
||||||
|
.ai-linkable:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.25);
|
||||||
|
}
|
||||||
|
.ai-link-flash {
|
||||||
|
animation: aiLinkFlash 0.9s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes aiLinkFlash {
|
||||||
|
0% {
|
||||||
|
background-color: rgba(50, 115, 220, 0.12);
|
||||||
|
box-shadow: 0 0 0 0 rgba(50, 115, 220, 0.24);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
background-color: rgba(50, 115, 220, 0.2);
|
||||||
|
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
box-shadow: 0 0 0 0 rgba(50, 115, 220, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.draft-option-card.is-selected {
|
.draft-option-card.is-selected {
|
||||||
border-color: rgba(54, 54, 54, 0.85) !important;
|
border-color: rgba(54, 54, 54, 0.85) !important;
|
||||||
border-width: 2px !important;
|
border-width: 2px !important;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<div id="mitigation-shell-{{ person.id }}" style="margin-top: 0.7rem;">
|
<div id="mitigation-shell-{{ person.id }}" style="margin-top: 0.7rem;">
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-start" style="gap: 0.5rem; margin-bottom: 0.5rem;">
|
<div class="is-flex is-justify-content-space-between is-align-items-start mitigation-header" style="gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
<div>
|
<div class="mitigation-header-main">
|
||||||
<p class="is-size-7 has-text-weight-semibold">Pattern Mitigation</p>
|
<p class="is-size-7 has-text-weight-semibold">Pattern Mitigation</p>
|
||||||
<h4 class="title is-6" style="margin-bottom: 0.2rem;">{{ plan.title|default:"Mitigation Plan" }}</h4>
|
<h4 class="title is-6" style="margin-bottom: 0.2rem;">{{ plan.title|default:"Mitigation Plan" }}</h4>
|
||||||
{% if plan.objective %}
|
{% if plan.objective %}
|
||||||
<p class="is-size-7">{{ plan.objective }}</p>
|
<p class="is-size-7">{{ plan.objective }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="is-flex is-flex-direction-column" style="gap: 0.35rem;">
|
<div class="is-flex is-flex-direction-column mitigation-header-meta" style="gap: 0.35rem;">
|
||||||
<span class="tag is-light">{{ plan.creation_mode|title }} / {{ plan.status|title }}</span>
|
<span class="tag is-light">{{ plan.creation_mode|title }} / {{ plan.status|title }}</span>
|
||||||
<span class="tag is-light">Created {{ plan.created_at }}</span>
|
<span class="tag is-light">Created {{ plan.created_at }}</span>
|
||||||
<span class="tag is-light">Updated {{ plan.updated_at }}</span>
|
<span class="tag is-light">Updated {{ plan.updated_at }}</span>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
||||||
<article class="box" style="min-height: 14rem; border: 1px solid rgba(0, 0, 0, 0.15); box-shadow: none;">
|
<div style="min-height: 14rem;">
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
|
||||||
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">RULES</p>
|
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">RULES</p>
|
||||||
<button
|
<button
|
||||||
@@ -106,36 +106,51 @@
|
|||||||
hx-swap="outerHTML">Delete All</button>
|
hx-swap="outerHTML">Delete All</button>
|
||||||
</div>
|
</div>
|
||||||
{% for rule in rules %}
|
{% for rule in rules %}
|
||||||
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
<article class="box mitigation-artifact-card" style="padding: 0.45rem; margin-bottom: 0.35rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Rule</span>
|
|
||||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ rule.created_at }}</span>
|
|
||||||
<form
|
<form
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
||||||
hx-target="#mitigation-shell-{{ person.id }}"
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
<div class="field" style="margin-bottom: 0.35rem;">
|
|
||||||
<input class="input is-small" type="text" name="title" value="{{ rule.title }}" data-editable="1" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="field" style="margin-bottom: 0.35rem;">
|
|
||||||
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ rule.content }}</textarea>
|
|
||||||
</div>
|
|
||||||
<label class="checkbox is-size-7" style="margin-bottom: 0.35rem;">
|
|
||||||
<input type="checkbox" name="enabled" value="1" {% if rule.enabled %}checked{% endif %}>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||||
<div class="buttons are-small" style="margin: 0;">
|
<div class="mitigation-artifact-headline">
|
||||||
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
|
<p class="mitigation-artifact-title">{{ rule.title }}</p>
|
||||||
|
<div class="mitigation-artifact-actions">
|
||||||
|
<label class="checkbox is-size-7 mitigation-artifact-enabled">
|
||||||
|
<input type="checkbox" name="enabled" value="1" data-editable-toggle="1" disabled {% if rule.enabled %}checked{% endif %}>
|
||||||
|
On
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-danger is-light"
|
class="button is-small is-link is-light is-rounded mitigation-edit-btn"
|
||||||
|
data-edit-state="view"
|
||||||
|
title="Edit rule"
|
||||||
|
onclick="giaMitigationToggleEdit(this); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-danger is-light is-rounded"
|
||||||
|
title="Delete rule"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
||||||
hx-vals='{"active_tab":"plan_board"}'
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
hx-confirm="Delete this rule?"
|
hx-confirm="Delete this rule?"
|
||||||
hx-target="#mitigation-shell-{{ person.id }}"
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
hx-swap="outerHTML">Delete</button>
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-trash"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mitigation-artifact-meta">Created {{ rule.created_at }}</p>
|
||||||
|
<p class="mitigation-artifact-preview">{{ rule.content }}</p>
|
||||||
|
<div class="mitigation-edit-fields">
|
||||||
|
<div class="field" style="margin-bottom: 0.3rem;">
|
||||||
|
<input class="input is-small" type="text" name="title" value="{{ rule.title }}" data-editable="1" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom: 0;">
|
||||||
|
<textarea class="textarea is-small" rows="2" name="body" data-editable="1" readonly>{{ rule.content }}</textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -144,11 +159,11 @@
|
|||||||
<p class="is-size-7 has-text-grey">No rules yet.</p>
|
<p class="is-size-7 has-text-grey">No rules yet.</p>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
||||||
<article class="box" style="min-height: 14rem; border: 1px solid rgba(0, 0, 0, 0.15); box-shadow: none;">
|
<div style="min-height: 14rem;">
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
|
||||||
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">GAMES</p>
|
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">GAMES</p>
|
||||||
<button
|
<button
|
||||||
@@ -162,36 +177,51 @@
|
|||||||
hx-swap="outerHTML">Delete All</button>
|
hx-swap="outerHTML">Delete All</button>
|
||||||
</div>
|
</div>
|
||||||
{% for game in games %}
|
{% for game in games %}
|
||||||
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
<article class="box mitigation-artifact-card" style="padding: 0.45rem; margin-bottom: 0.35rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Game</span>
|
|
||||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ game.created_at }}</span>
|
|
||||||
<form
|
<form
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
||||||
hx-target="#mitigation-shell-{{ person.id }}"
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
<div class="field" style="margin-bottom: 0.35rem;">
|
|
||||||
<input class="input is-small" type="text" name="title" value="{{ game.title }}" data-editable="1" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="field" style="margin-bottom: 0.35rem;">
|
|
||||||
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ game.instructions }}</textarea>
|
|
||||||
</div>
|
|
||||||
<label class="checkbox is-size-7" style="margin-bottom: 0.35rem;">
|
|
||||||
<input type="checkbox" name="enabled" value="1" {% if game.enabled %}checked{% endif %}>
|
|
||||||
Enabled
|
|
||||||
</label>
|
|
||||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||||
<div class="buttons are-small" style="margin: 0;">
|
<div class="mitigation-artifact-headline">
|
||||||
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
|
<p class="mitigation-artifact-title">{{ game.title }}</p>
|
||||||
|
<div class="mitigation-artifact-actions">
|
||||||
|
<label class="checkbox is-size-7 mitigation-artifact-enabled">
|
||||||
|
<input type="checkbox" name="enabled" value="1" data-editable-toggle="1" disabled {% if game.enabled %}checked{% endif %}>
|
||||||
|
On
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-danger is-light"
|
class="button is-small is-link is-light is-rounded mitigation-edit-btn"
|
||||||
|
data-edit-state="view"
|
||||||
|
title="Edit game"
|
||||||
|
onclick="giaMitigationToggleEdit(this); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-danger is-light is-rounded"
|
||||||
|
title="Delete game"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
||||||
hx-vals='{"active_tab":"plan_board"}'
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
hx-confirm="Delete this game?"
|
hx-confirm="Delete this game?"
|
||||||
hx-target="#mitigation-shell-{{ person.id }}"
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
hx-swap="outerHTML">Delete</button>
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-trash"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mitigation-artifact-meta">Created {{ game.created_at }}</p>
|
||||||
|
<p class="mitigation-artifact-preview">{{ game.instructions }}</p>
|
||||||
|
<div class="mitigation-edit-fields">
|
||||||
|
<div class="field" style="margin-bottom: 0.3rem;">
|
||||||
|
<input class="input is-small" type="text" name="title" value="{{ game.title }}" data-editable="1" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom: 0;">
|
||||||
|
<textarea class="textarea is-small" rows="2" name="body" data-editable="1" readonly>{{ game.instructions }}</textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -200,7 +230,7 @@
|
|||||||
<p class="is-size-7 has-text-grey">No games yet.</p>
|
<p class="is-size-7 has-text-grey">No games yet.</p>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,6 +645,110 @@
|
|||||||
45% { background-color: rgba(236, 246, 255, 1); }
|
45% { background-color: rgba(236, 246, 255, 1); }
|
||||||
100% { background-color: rgba(255, 255, 255, 1); }
|
100% { background-color: rgba(255, 255, 255, 1); }
|
||||||
}
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-headline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-enabled {
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
color: #6d7583;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-meta {
|
||||||
|
margin: 0.18rem 0 0.2rem;
|
||||||
|
font-size: 0.67rem;
|
||||||
|
color: #7b8492;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-preview {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
color: #4c5665;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-edit-fields {
|
||||||
|
display: none;
|
||||||
|
margin-top: 0.32rem;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-card.is-editing .mitigation-edit-fields {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-artifact-card.is-editing .mitigation-artifact-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-header-main,
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-header-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 16rem;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-header-meta .tag {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-top: 0.24em;
|
||||||
|
padding-bottom: 0.24em;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .columns,
|
||||||
|
#mitigation-shell-{{ person.id }} .column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .input,
|
||||||
|
#mitigation-shell-{{ person.id }} .textarea,
|
||||||
|
#mitigation-shell-{{ person.id }} .select,
|
||||||
|
#mitigation-shell-{{ person.id }} .select select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-header-main,
|
||||||
|
#mitigation-shell-{{ person.id }} .mitigation-header-meta {
|
||||||
|
flex-basis: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .field.is-grouped {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .field.is-grouped > .control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .tabs ul {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
#mitigation-shell-{{ person.id }} .engage-preview-flash {
|
#mitigation-shell-{{ person.id }} .engage-preview-flash {
|
||||||
animation: engagePreviewPulse 850ms ease-in-out 1;
|
animation: engagePreviewPulse 850ms ease-in-out 1;
|
||||||
}
|
}
|
||||||
@@ -675,15 +809,24 @@
|
|||||||
window.giaMitigationToggleEdit = function(button) {
|
window.giaMitigationToggleEdit = function(button) {
|
||||||
const form = button.closest("form");
|
const form = button.closest("form");
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
const card = form.closest(".mitigation-artifact-card");
|
||||||
const editing = button.dataset.editState === "edit";
|
const editing = button.dataset.editState === "edit";
|
||||||
const fields = form.querySelectorAll('[data-editable="1"]');
|
const fields = form.querySelectorAll('[data-editable="1"]');
|
||||||
|
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
fields.forEach(function(field) {
|
fields.forEach(function(field) {
|
||||||
field.removeAttribute("readonly");
|
field.removeAttribute("readonly");
|
||||||
});
|
});
|
||||||
|
toggles.forEach(function(field) {
|
||||||
|
field.removeAttribute("disabled");
|
||||||
|
});
|
||||||
|
if (card) {
|
||||||
|
card.classList.add("is-editing");
|
||||||
|
}
|
||||||
button.dataset.editState = "edit";
|
button.dataset.editState = "edit";
|
||||||
button.textContent = "Save";
|
|
||||||
button.classList.remove("is-light");
|
button.classList.remove("is-light");
|
||||||
|
button.title = "Save";
|
||||||
|
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
|
||||||
resizeEditableTextareas(form);
|
resizeEditableTextareas(form);
|
||||||
} else {
|
} else {
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
|
|||||||
@@ -112,33 +112,37 @@
|
|||||||
<div id="ai-response-shell-{{ person.id }}" style="display: block; margin-bottom: 0.9rem;">
|
<div id="ai-response-shell-{{ person.id }}" style="display: block; margin-bottom: 0.9rem;">
|
||||||
<div class="ai-response-capsule" style="margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.16); border-radius: 8px; padding: 0.5rem 0.6rem;">
|
<div class="ai-response-capsule" style="margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.16); border-radius: 8px; padding: 0.5rem 0.6rem;">
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.4rem;">
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.4rem;">
|
||||||
<div class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
|
<div class="tags" style="margin-bottom: 0.25rem;">
|
||||||
|
<span class="tag is-info is-light is-small">Mitigation</span>
|
||||||
|
<span class="tag is-warning is-light is-small">AI Output</span>
|
||||||
|
</div>
|
||||||
|
<div class="tabs is-small is-toggle is-toggle-rounded ai-top-tabs" style="margin-bottom: 0;">
|
||||||
<ul>
|
<ul>
|
||||||
<li id="ai-tab-{{ person.id }}-plan_board" class="is-active">
|
<li id="ai-tab-{{ person.id }}-plan_board" class="is-active ai-top-tab-mitigation">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'plan_board', false); return false;">Plan</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'plan_board', false); return false;">Plan</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-corrections">
|
<li id="ai-tab-{{ person.id }}-corrections" class="ai-top-tab-mitigation">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'corrections', false); return false;">Corrections</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'corrections', false); return false;">Corrections</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-engage">
|
<li id="ai-tab-{{ person.id }}-engage" class="ai-top-tab-mitigation">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'engage', false); return false;">Engage</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'engage', false); return false;">Engage</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-fundamentals">
|
<li id="ai-tab-{{ person.id }}-fundamentals" class="ai-top-tab-mitigation">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'fundamentals', false); return false;">Fundamentals</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'fundamentals', false); return false;">Fundamentals</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-auto">
|
<li id="ai-tab-{{ person.id }}-auto" class="ai-top-tab-mitigation">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'auto', false); return false;">Auto</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'auto', false); return false;">Auto</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-ask_ai">
|
<li id="ai-tab-{{ person.id }}-ask_ai" class="ai-top-tab-mitigation">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'ask_ai', false); return false;">Ask AI</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'ask_ai', false); return false;">Ask AI</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-summarise">
|
<li id="ai-tab-{{ person.id }}-summarise" class="ai-top-tab-output ai-top-tab-output-start">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'summarise', false); return false;">Summary</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'summarise', false); return false;">Summary</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-draft_reply">
|
<li id="ai-tab-{{ person.id }}-draft_reply" class="ai-top-tab-output">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'draft_reply', false); return false;">Draft</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'draft_reply', false); return false;">Draft</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="ai-tab-{{ person.id }}-extract_patterns">
|
<li id="ai-tab-{{ person.id }}-extract_patterns" class="ai-top-tab-output">
|
||||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'extract_patterns', false); return false;">Patterns</a>
|
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'extract_patterns', false); return false;">Patterns</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -179,6 +183,41 @@
|
|||||||
.ai-response-capsule {
|
.ai-response-capsule {
|
||||||
transition: all 180ms ease-out;
|
transition: all 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
.ai-person-widget .ai-top-tabs li.ai-top-tab-output-start {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
.ai-person-widget .ai-top-tabs li.ai-top-tab-mitigation a {
|
||||||
|
background: rgba(51, 114, 209, 0.08);
|
||||||
|
border-color: rgba(51, 114, 209, 0.26);
|
||||||
|
}
|
||||||
|
.ai-person-widget .ai-top-tabs li.ai-top-tab-output a {
|
||||||
|
background: rgba(245, 171, 53, 0.1);
|
||||||
|
border-color: rgba(223, 145, 22, 0.3);
|
||||||
|
}
|
||||||
|
.ai-person-widget .ai-top-tabs li.ai-top-tab-mitigation.is-active a {
|
||||||
|
background: #3273dc;
|
||||||
|
border-color: #3273dc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-person-widget .ai-top-tabs li.ai-top-tab-output.is-active a {
|
||||||
|
background: #e09116;
|
||||||
|
border-color: #e09116;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-person-widget .ai-top-tabs li a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.55rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.ai-person-widget .ai-top-tabs li a {
|
||||||
|
min-height: 2.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -639,15 +678,24 @@
|
|||||||
if (!form) {
|
if (!form) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const card = form.closest(".mitigation-artifact-card");
|
||||||
const editing = button.dataset.editState === "edit";
|
const editing = button.dataset.editState === "edit";
|
||||||
const fields = form.querySelectorAll('[data-editable="1"]');
|
const fields = form.querySelectorAll('[data-editable="1"]');
|
||||||
|
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
fields.forEach(function(field) {
|
fields.forEach(function(field) {
|
||||||
field.removeAttribute("readonly");
|
field.removeAttribute("readonly");
|
||||||
});
|
});
|
||||||
|
toggles.forEach(function(field) {
|
||||||
|
field.removeAttribute("disabled");
|
||||||
|
});
|
||||||
|
if (card) {
|
||||||
|
card.classList.add("is-editing");
|
||||||
|
}
|
||||||
button.dataset.editState = "edit";
|
button.dataset.editState = "edit";
|
||||||
button.textContent = "Save";
|
|
||||||
button.classList.remove("is-light");
|
button.classList.remove("is-light");
|
||||||
|
button.title = "Save";
|
||||||
|
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
|
||||||
} else {
|
} else {
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{{ service|title }} · {{ identifier }}
|
{{ service|title }} · {{ identifier }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons are-small" style="margin: 0;">
|
<div class="buttons are-small compose-top-actions" style="margin: 0;">
|
||||||
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
<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 class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
<span>Drafts</span>
|
<span>Drafts</span>
|
||||||
@@ -34,6 +34,12 @@
|
|||||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||||
<span>AI Workspace</span>
|
<span>AI Workspace</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% if render_mode == "page" %}
|
||||||
|
<a class="button is-light is-rounded" href="{{ compose_workspace_url }}">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||||
|
<span>Open In Workspace</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,6 +110,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="{{ panel_id }}-glance"
|
||||||
|
class="compose-glance{% if not glance_items %} is-hidden{% endif %}">
|
||||||
|
{% for item in glance_items %}
|
||||||
|
{% if item.url %}
|
||||||
|
<a class="compose-glance-item" href="{{ item.url }}" title="{{ item.tooltip }}">
|
||||||
|
<span class="compose-glance-key">{{ item.label }}</span>
|
||||||
|
<span class="compose-glance-val">{{ item.value }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="compose-glance-item" title="{{ item.tooltip }}">
|
||||||
|
<span class="compose-glance-key">{{ item.label }}</span>
|
||||||
|
<span class="compose-glance-val">{{ item.value }}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="{{ panel_id }}-thread"
|
id="{{ panel_id }}-thread"
|
||||||
class="compose-thread"
|
class="compose-thread"
|
||||||
@@ -122,23 +146,14 @@
|
|||||||
{% for msg in serialized_messages %}
|
{% for msg in serialized_messages %}
|
||||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
||||||
{% if msg.gap_fragments %}
|
{% if msg.gap_fragments %}
|
||||||
<div class="compose-gap-artifacts">
|
{% with gap=msg.gap_fragments.0 %}
|
||||||
{% for frag in msg.gap_fragments %}
|
<p
|
||||||
<article class="compose-artifact compose-artifact-gap">
|
class="compose-latency-chip"
|
||||||
<p class="compose-artifact-head">
|
title="{{ gap.focus|default:'Response 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-solid fa-hourglass-half"></i></span>
|
<span class="icon is-small"><i class="fa-regular fa-clock"></i></span>
|
||||||
<span>{{ frag.focus }} · {{ frag.lag }}</span>
|
<span class="compose-latency-val">{{ gap.lag|default:"-" }}</span>
|
||||||
<span class="compose-artifact-score">Score {{ frag.score }}</span>
|
|
||||||
</p>
|
</p>
|
||||||
{% if frag.calculation %}
|
{% endwith %}
|
||||||
<p class="compose-artifact-detail">How: {{ frag.calculation }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if frag.psychology %}
|
|
||||||
<p class="compose-artifact-detail">Meaning: {{ frag.psychology }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||||
{% if msg.image_urls %}
|
{% if msg.image_urls %}
|
||||||
@@ -171,21 +186,6 @@
|
|||||||
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
{% if msg.metric_fragments %}
|
|
||||||
<div class="compose-metric-artifacts">
|
|
||||||
{% for frag in msg.metric_fragments %}
|
|
||||||
<article
|
|
||||||
class="compose-artifact compose-artifact-metric"
|
|
||||||
title="How it is calculated: {{ frag.calculation }}{% if frag.psychology %} | Psychological interpretation: {{ frag.psychology }}{% endif %}">
|
|
||||||
<p class="compose-artifact-head">
|
|
||||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
|
||||||
<span>{{ frag.title }}</span>
|
|
||||||
<span class="compose-artifact-score">{{ frag.value }}</span>
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p class="compose-empty">No stored messages for this contact yet.</p>
|
<p class="compose-empty">No stored messages for this contact yet.</p>
|
||||||
@@ -206,6 +206,7 @@
|
|||||||
<input type="hidden" name="identifier" value="{{ identifier }}">
|
<input type="hidden" name="identifier" value="{{ identifier }}">
|
||||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||||
<input type="hidden" name="limit" value="{{ limit }}">
|
<input type="hidden" name="limit" value="{{ limit }}">
|
||||||
|
<input type="hidden" name="panel_id" value="{{ panel_id }}">
|
||||||
<input type="hidden" name="failsafe_arm" value="0">
|
<input type="hidden" name="failsafe_arm" value="0">
|
||||||
<input type="hidden" name="failsafe_confirm" value="0">
|
<input type="hidden" name="failsafe_confirm" value="0">
|
||||||
{% if person %}
|
{% if person %}
|
||||||
@@ -234,6 +235,7 @@
|
|||||||
<style>
|
<style>
|
||||||
#{{ panel_id }}.compose-shell {
|
#{{ panel_id }}.compose-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -263,6 +265,57 @@
|
|||||||
#{{ panel_id }} .compose-row.is-out {
|
#{{ panel_id }} .compose-row.is-out {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-middle,
|
||||||
|
#{{ panel_id }} .compose-row.is-group-first {
|
||||||
|
margin-bottom: 0.16rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-middle .compose-msg-meta,
|
||||||
|
#{{ panel_id }} .compose-row.is-group-first .compose-msg-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-first .compose-bubble.is-in {
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-middle .compose-bubble.is-in {
|
||||||
|
border-radius: 5px 8px 8px 5px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-last .compose-bubble.is-in {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-first .compose-bubble.is-out {
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-middle .compose-bubble.is-out {
|
||||||
|
border-radius: 8px 5px 5px 8px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-row.is-group-last .compose-bubble.is-out {
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-latency-chip {
|
||||||
|
align-self: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.02rem 0.28rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #6b7787;
|
||||||
|
font-size: 0.61rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: rgba(247, 249, 252, 0.88);
|
||||||
|
border: 1px solid rgba(103, 121, 145, 0.16);
|
||||||
|
}
|
||||||
|
#{{ 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-bubble {
|
#{{ panel_id }} .compose-bubble {
|
||||||
max-width: min(85%, 46rem);
|
max-width: min(85%, 46rem);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -319,6 +372,35 @@
|
|||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-artifact.compose-artifact-gap {
|
#{{ panel_id }} .compose-artifact.compose-artifact-gap {
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
|
padding: 0.18rem 0.3rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-gap-line {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #4d5b70;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-gap-lag,
|
||||||
|
#{{ panel_id }} .compose-gap-score {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-gap-track {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 2.8rem;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(83, 103, 130, 0.23);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-gap-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, rgba(80, 128, 196, 0.95), rgba(31, 95, 176, 0.95));
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-artifact-head {
|
#{{ panel_id }} .compose-artifact-head {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -399,6 +481,46 @@
|
|||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
min-height: 1.1rem;
|
min-height: 1.1rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-glance {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-glance.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-glance-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
background: rgba(250, 252, 255, 0.95);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} a.compose-glance-item:hover {
|
||||||
|
border-color: rgba(35, 84, 175, 0.45);
|
||||||
|
background: rgba(234, 243, 255, 0.96);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-glance-key {
|
||||||
|
color: #5b6a7c;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-glance-val {
|
||||||
|
color: #26384f;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-status-line {
|
#{{ panel_id }} .compose-status-line {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
@@ -413,17 +535,19 @@
|
|||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-ai-popover {
|
#{{ panel_id }} .compose-ai-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4.2rem;
|
top: 0;
|
||||||
right: 0.7rem;
|
left: 0;
|
||||||
width: min(34rem, calc(100% - 1.4rem));
|
width: min(40rem, calc(100% - 1rem));
|
||||||
z-index: 25;
|
margin-top: 0;
|
||||||
|
z-index: 35;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-ai-popover-backdrop {
|
#{{ panel_id }} .compose-ai-popover-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0.45rem;
|
inset: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: radial-gradient(circle at 100% 0%, rgba(238, 245, 255, 0.42), rgba(255, 255, 255, 0.15) 42%, rgba(255, 255, 255, 0));
|
background: radial-gradient(circle at 100% 0%, rgba(238, 245, 255, 0.42), rgba(255, 255, 255, 0.15) 42%, rgba(255, 255, 255, 0));
|
||||||
z-index: 24;
|
z-index: 34;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity 140ms ease-out;
|
transition: opacity 140ms ease-out;
|
||||||
@@ -477,18 +601,22 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
padding: 0.58rem 0.62rem;
|
padding: 0.58rem 0.62rem;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-draft-tone {
|
#{{ panel_id }} .compose-draft-tone {
|
||||||
margin: 0 0 0.2rem 0;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.28rem 0;
|
||||||
color: #6e7782;
|
color: #6e7782;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
white-space: nowrap;
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
white-space: normal;
|
||||||
text-overflow: ellipsis;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-draft-text {
|
#{{ panel_id }} .compose-draft-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -502,6 +630,21 @@
|
|||||||
#{{ panel_id }} .buttons {
|
#{{ panel_id }} .buttons {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-top-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-top-actions .button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.55rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-top-actions .button > span:last-child {
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
#{{ panel_id }} .js-ai-trigger {
|
#{{ panel_id }} .js-ai-trigger {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -575,18 +718,22 @@
|
|||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-qi-row-head {
|
#{{ panel_id }} .compose-qi-row-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-qi-row-label {
|
#{{ panel_id }} .compose-qi-row-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.32rem;
|
gap: 0.32rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-qi-row-meta {
|
#{{ panel_id }} .compose-qi-row-meta {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -594,6 +741,20 @@
|
|||||||
gap: 0.44rem;
|
gap: 0.44rem;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: #657283;
|
color: #657283;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.14rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-qi-row-meta > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.16rem;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-qi-row-body {
|
#{{ panel_id }} .compose-qi-row-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -672,9 +833,20 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
#{{ panel_id }} .compose-ai-popover {
|
#{{ panel_id }} .compose-ai-popover {
|
||||||
left: 0.7rem;
|
width: calc(100% - 1rem);
|
||||||
right: 0.7rem;
|
}
|
||||||
width: auto;
|
#{{ panel_id }} .compose-qi-row-label {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-qi-row-meta {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-qi-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-top-actions .button {
|
||||||
|
min-height: 2.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -695,6 +867,7 @@
|
|||||||
|
|
||||||
const statusBox = document.getElementById(panelId + "-status");
|
const statusBox = document.getElementById(panelId + "-status");
|
||||||
const typingNode = document.getElementById(panelId + "-typing");
|
const typingNode = document.getElementById(panelId + "-typing");
|
||||||
|
const glanceNode = document.getElementById(panelId + "-glance");
|
||||||
const popover = document.getElementById(panelId + "-popover");
|
const popover = document.getElementById(panelId + "-popover");
|
||||||
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
|
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
|
||||||
const csrfToken = "{{ csrf_token }}";
|
const csrfToken = "{{ csrf_token }}";
|
||||||
@@ -713,6 +886,9 @@
|
|||||||
if (previousState && previousState.docClickHandler) {
|
if (previousState && previousState.docClickHandler) {
|
||||||
document.removeEventListener("mousedown", previousState.docClickHandler);
|
document.removeEventListener("mousedown", previousState.docClickHandler);
|
||||||
}
|
}
|
||||||
|
if (previousState && previousState.resizeHandler) {
|
||||||
|
window.removeEventListener("resize", previousState.resizeHandler);
|
||||||
|
}
|
||||||
const panelState = {
|
const panelState = {
|
||||||
timer: null,
|
timer: null,
|
||||||
polling: false,
|
polling: false,
|
||||||
@@ -724,12 +900,68 @@
|
|||||||
window.giaComposePanels[panelId] = panelState;
|
window.giaComposePanels[panelId] = panelState;
|
||||||
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
||||||
|
|
||||||
|
const positionPopover = function (kind) {
|
||||||
|
if (!popover || popover.classList.contains("is-hidden")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const panelRect = panel.getBoundingClientRect();
|
||||||
|
const statusRect = statusBox ? statusBox.getBoundingClientRect() : null;
|
||||||
|
const trigger = triggerButtons.find(function (button) {
|
||||||
|
return button.dataset.kind === kind;
|
||||||
|
}) || triggerButtons[0] || null;
|
||||||
|
const triggerRect = trigger ? trigger.getBoundingClientRect() : null;
|
||||||
|
const anchorBottom = Math.max(
|
||||||
|
statusRect ? statusRect.bottom : panelRect.top,
|
||||||
|
triggerRect ? triggerRect.bottom : panelRect.top
|
||||||
|
);
|
||||||
|
const top = Math.max(8, Math.round(anchorBottom - panelRect.top + 8));
|
||||||
|
const maxWidth = Math.max(260, Math.round(panelRect.width - 16));
|
||||||
|
const width = Math.min(maxWidth, 640);
|
||||||
|
let left = 8;
|
||||||
|
if (triggerRect) {
|
||||||
|
const triggerCenter = (triggerRect.left + (triggerRect.width / 2)) - panelRect.left;
|
||||||
|
left = Math.round(triggerCenter - (width / 2));
|
||||||
|
}
|
||||||
|
left = Math.max(8, Math.min(left, Math.round(panelRect.width - width - 8)));
|
||||||
|
const maxHeight = Math.max(140, Math.round(panelRect.height - top - 8));
|
||||||
|
popover.style.top = String(top) + "px";
|
||||||
|
popover.style.left = String(left) + "px";
|
||||||
|
popover.style.width = String(width) + "px";
|
||||||
|
popover.style.maxHeight = String(maxHeight) + "px";
|
||||||
|
};
|
||||||
|
|
||||||
const toInt = function (value) {
|
const toInt = function (value) {
|
||||||
const parsed = parseInt(value || "0", 10);
|
const parsed = parseInt(value || "0", 10);
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const minuteBucketFromTs = function (tsValue) {
|
||||||
|
const ts = toInt(tsValue);
|
||||||
|
if (!ts) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return String(Math.floor(ts / 60000));
|
||||||
|
};
|
||||||
|
|
||||||
let lastTs = toInt(thread.dataset.lastTs);
|
let lastTs = toInt(thread.dataset.lastTs);
|
||||||
|
let glanceState = {
|
||||||
|
gap: null,
|
||||||
|
metrics: [],
|
||||||
|
};
|
||||||
|
const personId = String(thread.dataset.person || "").trim();
|
||||||
|
const insightUrlForMetric = function (metricSlug) {
|
||||||
|
const slug = String(metricSlug || "").trim();
|
||||||
|
if (!personId || !slug) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"/ai/workspace/page/person/"
|
||||||
|
+ encodeURIComponent(personId)
|
||||||
|
+ "/insights/"
|
||||||
|
+ encodeURIComponent(slug)
|
||||||
|
+ "/"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const autosize = function () {
|
const autosize = function () {
|
||||||
textarea.style.height = "auto";
|
textarea.style.height = "auto";
|
||||||
@@ -838,93 +1070,134 @@
|
|||||||
wireImageFallbacks(scope);
|
wireImageFallbacks(scope);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderGlanceItems = function (items) {
|
||||||
|
if (!glanceNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safe = Array.isArray(items) ? items.slice(0, 3) : [];
|
||||||
|
glanceNode.innerHTML = "";
|
||||||
|
if (!safe.length) {
|
||||||
|
glanceNode.classList.add("is-hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safe.forEach(function (item) {
|
||||||
|
const url = String(item.url || "").trim();
|
||||||
|
const chip = document.createElement(url ? "a" : "span");
|
||||||
|
chip.className = "compose-glance-item";
|
||||||
|
chip.title = String(item.tooltip || "");
|
||||||
|
if (url) {
|
||||||
|
chip.href = url;
|
||||||
|
}
|
||||||
|
const key = document.createElement("span");
|
||||||
|
key.className = "compose-glance-key";
|
||||||
|
key.textContent = String(item.label || "Info");
|
||||||
|
const val = document.createElement("span");
|
||||||
|
val.className = "compose-glance-val";
|
||||||
|
val.textContent = String(item.value || "-");
|
||||||
|
chip.appendChild(key);
|
||||||
|
chip.appendChild(val);
|
||||||
|
glanceNode.appendChild(chip);
|
||||||
|
});
|
||||||
|
glanceNode.classList.remove("is-hidden");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGlanceFromState = function () {
|
||||||
|
const items = [];
|
||||||
|
if (glanceState.gap) {
|
||||||
|
const gapMetricSlug = String(
|
||||||
|
glanceState.gap.slug || "inbound_response_score"
|
||||||
|
);
|
||||||
|
items.push({
|
||||||
|
label: "Response Delay",
|
||||||
|
value: String(glanceState.gap.lag || "-") + " · " + String(glanceState.gap.score || "-"),
|
||||||
|
tooltip: [
|
||||||
|
String(glanceState.gap.focus || "Response delay"),
|
||||||
|
"Delay " + String(glanceState.gap.lag || "-"),
|
||||||
|
"Score " + String(glanceState.gap.score || "-"),
|
||||||
|
glanceState.gap.calculation ? ("How it is calculated: " + String(glanceState.gap.calculation || "")) : "",
|
||||||
|
glanceState.gap.psychology ? ("Psychological interpretation: " + String(glanceState.gap.psychology || "")) : "",
|
||||||
|
].filter(Boolean).join(" | "),
|
||||||
|
url: insightUrlForMetric(gapMetricSlug),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(glanceState.metrics || []).slice(0, 2).forEach(function (metric) {
|
||||||
|
const metricSlug = String(metric.slug || "").trim();
|
||||||
|
items.push({
|
||||||
|
label: String(metric.title || "Metric"),
|
||||||
|
value: String(metric.value || "-"),
|
||||||
|
tooltip: [
|
||||||
|
metric.calculation ? ("How it is calculated: " + String(metric.calculation || "")) : "",
|
||||||
|
metric.psychology ? ("Psychological interpretation: " + String(metric.psychology || "")) : "",
|
||||||
|
].filter(Boolean).join(" | "),
|
||||||
|
url: insightUrlForMetric(metricSlug),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderGlanceItems(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGlanceFromMessage = function (msg) {
|
||||||
|
if (!msg || typeof msg !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
if (Array.isArray(msg.gap_fragments) && msg.gap_fragments.length) {
|
||||||
|
glanceState.gap = msg.gap_fragments[0];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(msg.metric_fragments) && msg.metric_fragments.length) {
|
||||||
|
glanceState.metrics = msg.metric_fragments.slice(0, 2);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateGlanceFromState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const latencyTooltip = function (gap) {
|
||||||
|
if (!gap || typeof gap !== "object") {
|
||||||
|
return "Response delay between turns.";
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
String(gap.focus || "Response delay between turns."),
|
||||||
|
"Latency " + String(gap.lag || "-"),
|
||||||
|
gap.calculation ? ("How it is calculated: " + String(gap.calculation || "")) : "",
|
||||||
|
gap.psychology ? ("Psychological interpretation: " + String(gap.psychology || "")) : "",
|
||||||
|
].filter(Boolean).join(" | ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendLatencyChip = function (row, msg) {
|
||||||
|
if (!row || !msg || !Array.isArray(msg.gap_fragments) || !msg.gap_fragments.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gap = msg.gap_fragments[0] || {};
|
||||||
|
const lag = String(gap.lag || "").trim();
|
||||||
|
if (!lag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chip = document.createElement("p");
|
||||||
|
chip.className = "compose-latency-chip";
|
||||||
|
chip.title = latencyTooltip(gap);
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "icon is-small";
|
||||||
|
const iconGlyph = document.createElement("i");
|
||||||
|
iconGlyph.className = "fa-regular fa-clock";
|
||||||
|
icon.appendChild(iconGlyph);
|
||||||
|
const value = document.createElement("span");
|
||||||
|
value.className = "compose-latency-val";
|
||||||
|
value.textContent = lag;
|
||||||
|
chip.appendChild(icon);
|
||||||
|
chip.appendChild(value);
|
||||||
|
row.appendChild(chip);
|
||||||
|
};
|
||||||
|
|
||||||
const appendBubble = function (msg) {
|
const appendBubble = function (msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
const outgoing = !!msg.outgoing;
|
const outgoing = !!msg.outgoing;
|
||||||
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
|
||||||
row.dataset.ts = String(msg.ts || 0);
|
row.dataset.ts = String(msg.ts || 0);
|
||||||
|
row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
|
||||||
const appendGapArtifacts = function (fragments) {
|
appendLatencyChip(row, msg);
|
||||||
if (!Array.isArray(fragments) || !fragments.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "compose-gap-artifacts";
|
|
||||||
fragments.forEach(function (fragment) {
|
|
||||||
const artifact = document.createElement("article");
|
|
||||||
artifact.className = "compose-artifact compose-artifact-gap";
|
|
||||||
const head = document.createElement("p");
|
|
||||||
head.className = "compose-artifact-head";
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "icon is-small";
|
|
||||||
icon.innerHTML = '<i class="fa-solid fa-hourglass-half"></i>';
|
|
||||||
const focus = document.createElement("span");
|
|
||||||
const focusText = String(fragment.focus || "Response gap");
|
|
||||||
const lagText = String(fragment.lag || "");
|
|
||||||
focus.textContent = lagText ? (focusText + " · " + lagText) : focusText;
|
|
||||||
const score = document.createElement("span");
|
|
||||||
score.className = "compose-artifact-score";
|
|
||||||
score.textContent = "Score " + String(fragment.score || "-");
|
|
||||||
head.appendChild(icon);
|
|
||||||
head.appendChild(focus);
|
|
||||||
head.appendChild(score);
|
|
||||||
artifact.appendChild(head);
|
|
||||||
if (fragment.calculation) {
|
|
||||||
const calc = document.createElement("p");
|
|
||||||
calc.className = "compose-artifact-detail";
|
|
||||||
calc.textContent = "How: " + String(fragment.calculation || "");
|
|
||||||
artifact.appendChild(calc);
|
|
||||||
}
|
|
||||||
if (fragment.psychology) {
|
|
||||||
const psych = document.createElement("p");
|
|
||||||
psych.className = "compose-artifact-detail";
|
|
||||||
psych.textContent = "Meaning: " + String(fragment.psychology || "");
|
|
||||||
artifact.appendChild(psych);
|
|
||||||
}
|
|
||||||
wrap.appendChild(artifact);
|
|
||||||
});
|
|
||||||
row.appendChild(wrap);
|
|
||||||
};
|
|
||||||
|
|
||||||
const appendMetricArtifacts = function (fragments) {
|
|
||||||
if (!Array.isArray(fragments) || !fragments.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "compose-metric-artifacts";
|
|
||||||
fragments.forEach(function (fragment) {
|
|
||||||
const artifact = document.createElement("article");
|
|
||||||
artifact.className = "compose-artifact compose-artifact-metric";
|
|
||||||
const calc = String(fragment.calculation || "");
|
|
||||||
const psych = String(fragment.psychology || "");
|
|
||||||
const tips = [];
|
|
||||||
if (calc) {
|
|
||||||
tips.push("How it is calculated: " + calc);
|
|
||||||
}
|
|
||||||
if (psych) {
|
|
||||||
tips.push("Psychological interpretation: " + psych);
|
|
||||||
}
|
|
||||||
artifact.title = tips.join(" | ");
|
|
||||||
const head = document.createElement("p");
|
|
||||||
head.className = "compose-artifact-head";
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "icon is-small";
|
|
||||||
icon.innerHTML = '<i class="fa-solid fa-chart-line"></i>';
|
|
||||||
const title = document.createElement("span");
|
|
||||||
title.textContent = String(fragment.title || "Metric");
|
|
||||||
const value = document.createElement("span");
|
|
||||||
value.className = "compose-artifact-score";
|
|
||||||
value.textContent = String(fragment.value || "-");
|
|
||||||
head.appendChild(icon);
|
|
||||||
head.appendChild(title);
|
|
||||||
head.appendChild(value);
|
|
||||||
artifact.appendChild(head);
|
|
||||||
wrap.appendChild(artifact);
|
|
||||||
});
|
|
||||||
row.appendChild(wrap);
|
|
||||||
};
|
|
||||||
|
|
||||||
appendGapArtifacts(msg.gap_fragments);
|
|
||||||
|
|
||||||
const bubble = document.createElement("article");
|
const bubble = document.createElement("article");
|
||||||
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
|
||||||
@@ -963,13 +1236,51 @@
|
|||||||
bubble.appendChild(meta);
|
bubble.appendChild(meta);
|
||||||
|
|
||||||
row.appendChild(bubble);
|
row.appendChild(bubble);
|
||||||
appendMetricArtifacts(msg.metric_fragments);
|
|
||||||
const empty = thread.querySelector(".compose-empty");
|
const empty = thread.querySelector(".compose-empty");
|
||||||
if (empty) {
|
if (empty) {
|
||||||
empty.remove();
|
empty.remove();
|
||||||
}
|
}
|
||||||
thread.appendChild(row);
|
thread.appendChild(row);
|
||||||
wireImageFallbacks(row);
|
wireImageFallbacks(row);
|
||||||
|
updateGlanceFromMessage(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMinuteGrouping = function () {
|
||||||
|
const rows = Array.from(thread.querySelectorAll(".compose-row"));
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
row.classList.remove(
|
||||||
|
"is-group-single",
|
||||||
|
"is-group-first",
|
||||||
|
"is-group-middle",
|
||||||
|
"is-group-last"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
const row = rows[i];
|
||||||
|
const minute = String(row.dataset.minute || minuteBucketFromTs(row.dataset.ts));
|
||||||
|
const side = row.classList.contains("is-out") ? "out" : "in";
|
||||||
|
const prev = rows[i - 1] || null;
|
||||||
|
const next = rows[i + 1] || null;
|
||||||
|
const prevMatch = !!(
|
||||||
|
prev
|
||||||
|
&& (prev.classList.contains("is-out") ? "out" : "in") === side
|
||||||
|
&& String(prev.dataset.minute || minuteBucketFromTs(prev.dataset.ts)) === minute
|
||||||
|
);
|
||||||
|
const nextMatch = !!(
|
||||||
|
next
|
||||||
|
&& (next.classList.contains("is-out") ? "out" : "in") === side
|
||||||
|
&& String(next.dataset.minute || minuteBucketFromTs(next.dataset.ts)) === minute
|
||||||
|
);
|
||||||
|
if (prevMatch && nextMatch) {
|
||||||
|
row.classList.add("is-group-middle");
|
||||||
|
} else if (!prevMatch && nextMatch) {
|
||||||
|
row.classList.add("is-group-first");
|
||||||
|
} else if (prevMatch && !nextMatch) {
|
||||||
|
row.classList.add("is-group-last");
|
||||||
|
} else {
|
||||||
|
row.classList.add("is-group-single");
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendMessages = function (messages, forceScroll) {
|
const appendMessages = function (messages, forceScroll) {
|
||||||
@@ -980,6 +1291,7 @@
|
|||||||
});
|
});
|
||||||
thread.dataset.lastTs = String(lastTs);
|
thread.dataset.lastTs = String(lastTs);
|
||||||
if ((messages || []).length > 0) {
|
if ((messages || []).length > 0) {
|
||||||
|
applyMinuteGrouping();
|
||||||
scrollToBottom(shouldStick);
|
scrollToBottom(shouldStick);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1106,6 +1418,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore invalid initial typing state payload.
|
// Ignore invalid initial typing state payload.
|
||||||
}
|
}
|
||||||
|
applyMinuteGrouping();
|
||||||
|
|
||||||
const setStatus = function (message, level) {
|
const setStatus = function (message, level) {
|
||||||
if (!statusBox) {
|
if (!statusBox) {
|
||||||
@@ -1132,8 +1445,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setActiveTrigger = function (kind) {
|
const setActiveTrigger = function (kind) {
|
||||||
|
const popoverVisible = !!(popover && !popover.classList.contains("is-hidden"));
|
||||||
|
const activeCardVisible = !!(
|
||||||
|
popover
|
||||||
|
&& kind
|
||||||
|
&& popover.querySelector('.compose-ai-card[data-kind="' + kind + '"].is-active')
|
||||||
|
);
|
||||||
triggerButtons.forEach(function (button) {
|
triggerButtons.forEach(function (button) {
|
||||||
button.classList.toggle("is-expanded", !!kind && button.dataset.kind === kind);
|
const expanded = popoverVisible && activeCardVisible && button.dataset.kind === kind;
|
||||||
|
button.classList.toggle("is-expanded", expanded);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1170,6 +1490,7 @@
|
|||||||
});
|
});
|
||||||
panelState.activePanel = kind;
|
panelState.activePanel = kind;
|
||||||
setActiveTrigger(kind);
|
setActiveTrigger(kind);
|
||||||
|
positionPopover(kind);
|
||||||
return active;
|
return active;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1670,6 +1991,13 @@
|
|||||||
hideAllCards();
|
hideAllCards();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
panelState.resizeHandler = function () {
|
||||||
|
if (!popover || popover.classList.contains("is-hidden")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
positionPopover(panelState.activePanel);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", panelState.resizeHandler);
|
||||||
document.addEventListener("mousedown", panelState.docClickHandler);
|
document.addEventListener("mousedown", panelState.docClickHandler);
|
||||||
textarea.addEventListener("keydown", function (event) {
|
textarea.addEventListener("keydown", function (event) {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
@@ -1686,13 +2014,22 @@
|
|||||||
textarea.focus();
|
textarea.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
panelState.eventHandler = function () {
|
panelState.eventHandler = function (event) {
|
||||||
|
const detail = (event && event.detail) || {};
|
||||||
|
const sourcePanelId = String(detail.panel_id || "");
|
||||||
|
if (!sourcePanelId || sourcePanelId !== panelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
poll(true);
|
poll(true);
|
||||||
};
|
};
|
||||||
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
||||||
|
|
||||||
panelState.sendResultHandler = function (event) {
|
panelState.sendResultHandler = function (event) {
|
||||||
const detail = (event && event.detail) || {};
|
const detail = (event && event.detail) || {};
|
||||||
|
const sourcePanelId = String(detail.panel_id || "");
|
||||||
|
if (!sourcePanelId || sourcePanelId !== panelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ok = !!detail.ok;
|
const ok = !!detail.ok;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
flashCompose("is-send-success");
|
flashCompose("is-send-success");
|
||||||
|
|||||||
114
core/templates/partials/compose-workspace-contacts-widget.html
Normal file
114
core/templates/partials/compose-workspace-contacts-widget.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<div class="compose-workspace-widget">
|
||||||
|
<div class="columns is-mobile is-gapless">
|
||||||
|
<div class="column is-12-mobile is-12-tablet">
|
||||||
|
<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">Manual Workspace</p>
|
||||||
|
<h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3>
|
||||||
|
<p class="is-size-7">
|
||||||
|
Open one or more direct chat widgets and keep them live in this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="compose-workspace-window-form"
|
||||||
|
style="
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
">
|
||||||
|
<label class="label is-small" for="compose-workspace-limit">Window</label>
|
||||||
|
<div class="select is-fullwidth is-small">
|
||||||
|
<select id="compose-workspace-limit" name="limit">
|
||||||
|
{% for option in limit_options %}
|
||||||
|
<option value="{{ option }}" {% if option == limit %}selected{% endif %}>
|
||||||
|
{{ option }} messages
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
How many recent messages to load in each new message widget.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% if contact_rows %}
|
||||||
|
<div class="buttons are-small" style="display: grid; gap: 0.5rem;">
|
||||||
|
{% for row in contact_rows %}
|
||||||
|
<button
|
||||||
|
class="button is-fullwidth"
|
||||||
|
style="
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
"
|
||||||
|
hx-get="{{ row.compose_widget_url }}"
|
||||||
|
hx-include="#compose-workspace-window-form"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="afterend">
|
||||||
|
<span
|
||||||
|
class="tags has-addons"
|
||||||
|
style="
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
">
|
||||||
|
<span
|
||||||
|
class="tag is-dark"
|
||||||
|
style="min-width: 2.5rem; justify-content: center;">
|
||||||
|
<i class="{{ manual_icon_class }}" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="tag is-white"
|
||||||
|
style="
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
padding-right: 0.7rem;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
min-width: 0;
|
||||||
|
">
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
">
|
||||||
|
<strong>{{ row.person_name }}</strong>
|
||||||
|
<small class="has-text-grey">{{ row.service|title }}</small>
|
||||||
|
</span>
|
||||||
|
<small
|
||||||
|
class="has-text-grey"
|
||||||
|
style="
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: right;
|
||||||
|
">
|
||||||
|
{{ row.identifier }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="has-text-grey">No contacts available yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -10,4 +10,14 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<a class="navbar-item is-disabled">No contacts found.</a>
|
<a class="navbar-item is-disabled">No contacts found.</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_preview %}
|
||||||
|
<hr class="navbar-divider" style="margin: 0.2rem 0;">
|
||||||
|
<a
|
||||||
|
class="navbar-item"
|
||||||
|
hx-get="{{ fetch_contacts_url }}"
|
||||||
|
hx-target="#nav-compose-contacts"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-users"></i></span>
|
||||||
|
<span style="margin-left: 0.35rem;">Fetch Contacts</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from django.core import signing
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponseBadRequest, JsonResponse
|
from django.http import HttpResponseBadRequest, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.urls import reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
from django.utils import timezone as dj_timezone
|
from django.utils import timezone as dj_timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
@@ -426,10 +426,14 @@ def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
|
|||||||
)
|
)
|
||||||
if score_value is None:
|
if score_value is None:
|
||||||
score_value = _score_from_lag_for_thread(lag_ms)
|
score_value = _score_from_lag_for_thread(lag_ms)
|
||||||
|
score_value = max(0.0, min(100.0, float(score_value)))
|
||||||
return {
|
return {
|
||||||
|
"slug": metric_slug,
|
||||||
"title": "Unseen Gap",
|
"title": "Unseen Gap",
|
||||||
"focus": "Your reply delay" if is_outgoing_reply else "Counterpart reply delay",
|
"focus": "Your reply delay" if is_outgoing_reply else "Counterpart reply delay",
|
||||||
"lag": _format_gap_duration(lag_ms),
|
"lag": _format_gap_duration(lag_ms),
|
||||||
|
"lag_ms": int(lag_ms or 0),
|
||||||
|
"score_value": round(score_value, 2),
|
||||||
"score": _format_metric_fragment_value(score_value, 2),
|
"score": _format_metric_fragment_value(score_value, 2),
|
||||||
"calculation": copy["calculation"],
|
"calculation": copy["calculation"],
|
||||||
"psychology": copy["psychology"],
|
"psychology": copy["psychology"],
|
||||||
@@ -483,6 +487,90 @@ def _serialize_messages_with_artifacts(
|
|||||||
return serialized
|
return serialized
|
||||||
|
|
||||||
|
|
||||||
|
def _insight_detail_url(person_id, metric_slug):
|
||||||
|
if not person_id or not metric_slug:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return reverse(
|
||||||
|
"ai_workspace_insight_detail",
|
||||||
|
kwargs={
|
||||||
|
"type": "page",
|
||||||
|
"person_id": person_id,
|
||||||
|
"metric": str(metric_slug),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except NoReverseMatch:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _glance_items_from_state(gap_fragment=None, metric_fragments=None, person_id=None):
|
||||||
|
items = []
|
||||||
|
if gap_fragment:
|
||||||
|
tooltip_parts = [
|
||||||
|
f"{gap_fragment.get('focus') or 'Response delay'}",
|
||||||
|
f"Delay {gap_fragment.get('lag') or '-'}",
|
||||||
|
f"Score {gap_fragment.get('score') or '-'}",
|
||||||
|
]
|
||||||
|
if gap_fragment.get("calculation"):
|
||||||
|
tooltip_parts.append(
|
||||||
|
f"How it is calculated: {gap_fragment.get('calculation')}"
|
||||||
|
)
|
||||||
|
if gap_fragment.get("psychology"):
|
||||||
|
tooltip_parts.append(
|
||||||
|
f"Psychological interpretation: {gap_fragment.get('psychology')}"
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"label": "Response Delay",
|
||||||
|
"value": f"{gap_fragment.get('lag') or '-'} · {gap_fragment.get('score') or '-'}",
|
||||||
|
"tooltip": " | ".join(tooltip_parts),
|
||||||
|
"url": _insight_detail_url(
|
||||||
|
person_id,
|
||||||
|
gap_fragment.get("slug") or "inbound_response_score",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
metric_fragments = list(metric_fragments or [])
|
||||||
|
for metric in metric_fragments[:2]:
|
||||||
|
tooltip_parts = []
|
||||||
|
if metric.get("calculation"):
|
||||||
|
tooltip_parts.append(f"How it is calculated: {metric.get('calculation')}")
|
||||||
|
if metric.get("psychology"):
|
||||||
|
tooltip_parts.append(
|
||||||
|
f"Psychological interpretation: {metric.get('psychology')}"
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"label": str(metric.get("title") or "Metric"),
|
||||||
|
"value": str(metric.get("value") or "-"),
|
||||||
|
"tooltip": " | ".join(tooltip_parts),
|
||||||
|
"url": _insight_detail_url(person_id, metric.get("slug")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_glance_items(serialized_messages, person_id=None):
|
||||||
|
rows = list(serialized_messages or [])
|
||||||
|
latest_metrics = []
|
||||||
|
latest_gap = None
|
||||||
|
for row in reversed(rows):
|
||||||
|
row_metrics = list(row.get("metric_fragments") or [])
|
||||||
|
if row_metrics and not latest_metrics:
|
||||||
|
latest_metrics = row_metrics
|
||||||
|
row_gaps = list(row.get("gap_fragments") or [])
|
||||||
|
if row_gaps and latest_gap is None:
|
||||||
|
latest_gap = row_gaps[0]
|
||||||
|
if latest_metrics and latest_gap:
|
||||||
|
break
|
||||||
|
return _glance_items_from_state(
|
||||||
|
latest_gap,
|
||||||
|
latest_metrics,
|
||||||
|
person_id=person_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _owner_name(user) -> str:
|
def _owner_name(user) -> str:
|
||||||
return (
|
return (
|
||||||
user.first_name
|
user.first_name
|
||||||
@@ -1040,6 +1128,7 @@ def _compose_urls(service, identifier, person_id):
|
|||||||
return {
|
return {
|
||||||
"page_url": f"{reverse('compose_page')}?{payload}",
|
"page_url": f"{reverse('compose_page')}?{payload}",
|
||||||
"widget_url": f"{reverse('compose_widget')}?{payload}",
|
"widget_url": f"{reverse('compose_widget')}?{payload}",
|
||||||
|
"workspace_url": f"{reverse('compose_workspace')}?{payload}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1076,6 +1165,15 @@ def _panel_context(
|
|||||||
counterpart_identifiers = _counterpart_identifiers_for_person(
|
counterpart_identifiers = _counterpart_identifiers_for_person(
|
||||||
request.user, base["person"]
|
request.user, base["person"]
|
||||||
)
|
)
|
||||||
|
serialized_messages = _serialize_messages_with_artifacts(
|
||||||
|
session_bundle["messages"],
|
||||||
|
counterpart_identifiers=counterpart_identifiers,
|
||||||
|
conversation=conversation,
|
||||||
|
)
|
||||||
|
glance_items = _build_glance_items(
|
||||||
|
serialized_messages,
|
||||||
|
person_id=(base["person"].id if base["person"] else None),
|
||||||
|
)
|
||||||
last_ts = 0
|
last_ts = 0
|
||||||
if session_bundle["messages"]:
|
if session_bundle["messages"]:
|
||||||
last_ts = int(session_bundle["messages"][-1].ts or 0)
|
last_ts = int(session_bundle["messages"][-1].ts or 0)
|
||||||
@@ -1106,11 +1204,9 @@ def _panel_context(
|
|||||||
"person_identifier": base["person_identifier"],
|
"person_identifier": base["person_identifier"],
|
||||||
"session": session_bundle["session"],
|
"session": session_bundle["session"],
|
||||||
"messages": session_bundle["messages"],
|
"messages": session_bundle["messages"],
|
||||||
"serialized_messages": _serialize_messages_with_artifacts(
|
"serialized_messages": serialized_messages,
|
||||||
session_bundle["messages"],
|
"glance_items": glance_items,
|
||||||
counterpart_identifiers=counterpart_identifiers,
|
"glance_items_json": json.dumps(glance_items),
|
||||||
conversation=conversation,
|
|
||||||
),
|
|
||||||
"last_ts": last_ts,
|
"last_ts": last_ts,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"notice_message": notice,
|
"notice_message": notice,
|
||||||
@@ -1118,6 +1214,9 @@ def _panel_context(
|
|||||||
"render_mode": render_mode,
|
"render_mode": render_mode,
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_workspace_url": (
|
||||||
|
f"{urls['workspace_url']}&{urlencode({'limit': limit})}"
|
||||||
|
),
|
||||||
"compose_drafts_url": reverse("compose_drafts"),
|
"compose_drafts_url": reverse("compose_drafts"),
|
||||||
"compose_summary_url": reverse("compose_summary"),
|
"compose_summary_url": reverse("compose_summary"),
|
||||||
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
||||||
@@ -1137,11 +1236,15 @@ def _panel_context(
|
|||||||
|
|
||||||
class ComposeContactsDropdown(LoginRequiredMixin, View):
|
class ComposeContactsDropdown(LoginRequiredMixin, View):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
rows = list(
|
all_value = str(request.GET.get("all") or "").strip().lower()
|
||||||
|
fetch_all = all_value in {"1", "true", "yes", "y", "all"}
|
||||||
|
preview_limit = 5
|
||||||
|
queryset = (
|
||||||
PersonIdentifier.objects.filter(user=request.user)
|
PersonIdentifier.objects.filter(user=request.user)
|
||||||
.select_related("person")
|
.select_related("person")
|
||||||
.order_by("person__name", "service", "identifier")
|
.order_by("person__name", "service", "identifier")
|
||||||
)
|
)
|
||||||
|
rows = list(queryset) if fetch_all else list(queryset[:preview_limit])
|
||||||
items = []
|
items = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
||||||
@@ -1159,10 +1262,83 @@ class ComposeContactsDropdown(LoginRequiredMixin, View):
|
|||||||
{
|
{
|
||||||
"items": items,
|
"items": items,
|
||||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
|
"is_preview": not fetch_all,
|
||||||
|
"fetch_contacts_url": f"{reverse('compose_contacts_dropdown')}?all=1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeWorkspace(LoginRequiredMixin, View):
|
||||||
|
template_name = "pages/compose-workspace.html"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
service = _default_service(request.GET.get("service"))
|
||||||
|
identifier = str(request.GET.get("identifier") or "").strip()
|
||||||
|
person = None
|
||||||
|
person_id = request.GET.get("person")
|
||||||
|
if person_id:
|
||||||
|
person = Person.objects.filter(id=person_id, user=request.user).first()
|
||||||
|
limit = _safe_limit(request.GET.get("limit") or 40)
|
||||||
|
|
||||||
|
initial_widget_url = ""
|
||||||
|
if identifier or person is not None:
|
||||||
|
base = _context_base(request.user, service, identifier, person)
|
||||||
|
if base["identifier"]:
|
||||||
|
urls = _compose_urls(
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
base["person"].id if base["person"] else None,
|
||||||
|
)
|
||||||
|
initial_widget_url = (
|
||||||
|
f"{urls['widget_url']}&{urlencode({'limit': limit})}"
|
||||||
|
)
|
||||||
|
|
||||||
|
contacts_widget_url = (
|
||||||
|
f"{reverse('compose_workspace_contacts_widget')}"
|
||||||
|
f"?{urlencode({'limit': limit})}"
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"contacts_widget_url": contacts_widget_url,
|
||||||
|
"initial_widget_url": initial_widget_url,
|
||||||
|
}
|
||||||
|
return render(request, self.template_name, context)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
|
||||||
|
def _contact_rows(self, user):
|
||||||
|
rows = []
|
||||||
|
queryset = (
|
||||||
|
PersonIdentifier.objects.filter(user=user)
|
||||||
|
.select_related("person")
|
||||||
|
.order_by("person__name", "service", "identifier")
|
||||||
|
)
|
||||||
|
for row in queryset:
|
||||||
|
urls = _compose_urls(row.service, row.identifier, row.person_id)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"person_name": row.person.name,
|
||||||
|
"service": row.service,
|
||||||
|
"identifier": row.identifier,
|
||||||
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
limit = _safe_limit(request.GET.get("limit") or 40)
|
||||||
|
context = {
|
||||||
|
"title": "Manual Workspace",
|
||||||
|
"unique": "compose-workspace-contacts",
|
||||||
|
"window_content": "partials/compose-workspace-contacts-widget.html",
|
||||||
|
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
|
||||||
|
"contact_rows": self._contact_rows(request.user),
|
||||||
|
"limit": limit,
|
||||||
|
"limit_options": [20, 40, 60, 100, 200],
|
||||||
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
|
}
|
||||||
|
return render(request, "mixins/wm/widget.html", context)
|
||||||
|
|
||||||
|
|
||||||
class ComposePage(LoginRequiredMixin, View):
|
class ComposePage(LoginRequiredMixin, View):
|
||||||
template_name = "pages/compose.html"
|
template_name = "pages/compose.html"
|
||||||
|
|
||||||
@@ -1704,7 +1880,7 @@ class ComposeEngageSend(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
class ComposeSend(LoginRequiredMixin, View):
|
class ComposeSend(LoginRequiredMixin, View):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _response(request, *, ok, message="", level="info"):
|
def _response(request, *, ok, message="", level="info", panel_id=""):
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
"partials/compose-send-status.html",
|
"partials/compose-send-status.html",
|
||||||
@@ -1718,10 +1894,11 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
"ok": bool(ok),
|
"ok": bool(ok),
|
||||||
"message": str(message or ""),
|
"message": str(message or ""),
|
||||||
"level": str(level or "info"),
|
"level": str(level or "info"),
|
||||||
|
"panel_id": str(panel_id or ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ok:
|
if ok:
|
||||||
trigger_payload["composeMessageSent"] = True
|
trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
|
||||||
response["HX-Trigger"] = json.dumps(trigger_payload)
|
response["HX-Trigger"] = json.dumps(trigger_payload)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -1735,6 +1912,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
|
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
|
||||||
if render_mode not in {"page", "widget"}:
|
if render_mode not in {"page", "widget"}:
|
||||||
render_mode = "page"
|
render_mode = "page"
|
||||||
|
panel_id = str(request.POST.get("panel_id") or "").strip()
|
||||||
|
|
||||||
if not identifier and person is None:
|
if not identifier and person is None:
|
||||||
return HttpResponseBadRequest("Missing contact identifier.")
|
return HttpResponseBadRequest("Missing contact identifier.")
|
||||||
@@ -1747,6 +1925,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
ok=False,
|
ok=False,
|
||||||
message="Enable send confirmation before sending.",
|
message="Enable send confirmation before sending.",
|
||||||
level="warning",
|
level="warning",
|
||||||
|
panel_id=panel_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
text = str(request.POST.get("text") or "").strip()
|
text = str(request.POST.get("text") or "").strip()
|
||||||
@@ -1756,6 +1935,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
ok=False,
|
ok=False,
|
||||||
message="Message is empty.",
|
message="Message is empty.",
|
||||||
level="danger",
|
level="danger",
|
||||||
|
panel_id=panel_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
base = _context_base(request.user, service, identifier, person)
|
base = _context_base(request.user, service, identifier, person)
|
||||||
@@ -1771,6 +1951,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
ok=False,
|
ok=False,
|
||||||
message="Send failed. Check service account state.",
|
message="Send failed. Check service account state.",
|
||||||
level="danger",
|
level="danger",
|
||||||
|
panel_id=panel_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if base["person_identifier"] is not None:
|
if base["person_identifier"] is not None:
|
||||||
@@ -1788,4 +1969,10 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
custom_author="USER",
|
custom_author="USER",
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._response(request, ok=True, message="", level="success")
|
return self._response(
|
||||||
|
request,
|
||||||
|
ok=True,
|
||||||
|
message="",
|
||||||
|
level="success",
|
||||||
|
panel_id=panel_id,
|
||||||
|
)
|
||||||
|
|||||||
@@ -495,6 +495,17 @@ INSIGHT_GRAPH_SPECS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
INFORMATION_OVERVIEW_SLUGS = (
|
||||||
|
"platform",
|
||||||
|
"thread",
|
||||||
|
"workspace_created",
|
||||||
|
"stability_state",
|
||||||
|
"stability_computed",
|
||||||
|
"commitment_computed",
|
||||||
|
"last_event",
|
||||||
|
"last_ai_run",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _format_unix_ms(ts):
|
def _format_unix_ms(ts):
|
||||||
if not ts:
|
if not ts:
|
||||||
@@ -892,6 +903,27 @@ def _all_graph_payload(conversation):
|
|||||||
return graphs
|
return graphs
|
||||||
|
|
||||||
|
|
||||||
|
def _information_overview_rows(conversation):
|
||||||
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
|
rows = []
|
||||||
|
for slug in INFORMATION_OVERVIEW_SLUGS:
|
||||||
|
spec = INSIGHT_METRICS.get(slug)
|
||||||
|
if not spec:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"title": spec.get("title") or slug.replace("_", " ").title(),
|
||||||
|
"value": _format_metric_value(conversation, slug, latest_snapshot),
|
||||||
|
"group": spec.get("group"),
|
||||||
|
"group_title": INSIGHT_GROUPS.get(spec.get("group"), {}).get(
|
||||||
|
"title", "Information"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _commitment_directionality_payload(conversation):
|
def _commitment_directionality_payload(conversation):
|
||||||
latest_snapshot = conversation.metric_snapshots.first()
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
inbound = conversation.commitment_inbound_score
|
inbound = conversation.commitment_inbound_score
|
||||||
@@ -1574,6 +1606,26 @@ def _parse_result_sections(result_text):
|
|||||||
|
|
||||||
|
|
||||||
def _build_interaction_signals(operation, result_text, message_event_ids):
|
def _build_interaction_signals(operation, result_text, message_event_ids):
|
||||||
|
def _normalize_signal_key(value):
|
||||||
|
key = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
|
||||||
|
if key == "open_loops":
|
||||||
|
return "open_loop"
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _signal_display_label(label, key):
|
||||||
|
if str(label or "").strip():
|
||||||
|
return str(label).strip().title()
|
||||||
|
return str(key or "Signal").replace("_", " ").strip().title()
|
||||||
|
|
||||||
|
meaning_by_key = {
|
||||||
|
"repair": "Repair markers suggest active attempts to restore connection.",
|
||||||
|
"de_escalation": "De-escalation markers suggest pressure reduction and safer tone.",
|
||||||
|
"open_loop": "Open loops are unresolved topics likely to reappear later.",
|
||||||
|
"risk": "Risk markers indicate escalating friction or potential rupture points.",
|
||||||
|
"conflict": "Conflict markers indicate direct tension or adversarial framing.",
|
||||||
|
"draft_generated": "Draft generation indicates actionable next-step options are available.",
|
||||||
|
}
|
||||||
|
|
||||||
text = (result_text or "").lower()
|
text = (result_text or "").lower()
|
||||||
signals = []
|
signals = []
|
||||||
heuristics = [
|
heuristics = [
|
||||||
@@ -1585,17 +1637,25 @@ def _build_interaction_signals(operation, result_text, message_event_ids):
|
|||||||
]
|
]
|
||||||
for label, token, valence in heuristics:
|
for label, token, valence in heuristics:
|
||||||
if token in text:
|
if token in text:
|
||||||
|
signal_key = _normalize_signal_key(label)
|
||||||
signals.append(
|
signals.append(
|
||||||
{
|
{
|
||||||
"label": label,
|
"label": label,
|
||||||
|
"display_label": _signal_display_label(label, signal_key),
|
||||||
|
"signal_key": signal_key,
|
||||||
|
"meaning": meaning_by_key.get(signal_key, ""),
|
||||||
"valence": valence,
|
"valence": valence,
|
||||||
"message_event_ids": message_event_ids[:6],
|
"message_event_ids": message_event_ids[:6],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not signals and operation == "draft_reply":
|
if not signals and operation == "draft_reply":
|
||||||
|
signal_key = _normalize_signal_key("draft_generated")
|
||||||
signals.append(
|
signals.append(
|
||||||
{
|
{
|
||||||
"label": "draft_generated",
|
"label": "draft_generated",
|
||||||
|
"display_label": _signal_display_label("draft_generated", signal_key),
|
||||||
|
"signal_key": signal_key,
|
||||||
|
"meaning": meaning_by_key.get(signal_key, ""),
|
||||||
"valence": "positive",
|
"valence": "positive",
|
||||||
"message_event_ids": message_event_ids[:3],
|
"message_event_ids": message_event_ids[:3],
|
||||||
}
|
}
|
||||||
@@ -1662,12 +1722,26 @@ def _build_memory_proposals(operation, result_text):
|
|||||||
|
|
||||||
|
|
||||||
def _group_memory_proposals(memory_proposals):
|
def _group_memory_proposals(memory_proposals):
|
||||||
|
signal_keys_by_kind = {
|
||||||
|
"open_loops": ["open_loop"],
|
||||||
|
"emotional_state": ["de_escalation", "repair", "conflict"],
|
||||||
|
"patterns": ["repair", "conflict"],
|
||||||
|
"friction_loops": ["conflict", "risk"],
|
||||||
|
"summary": ["open_loop", "repair", "de_escalation", "conflict", "risk"],
|
||||||
|
"rules": ["repair", "conflict", "risk"],
|
||||||
|
"insights": ["open_loop", "repair", "de_escalation", "conflict", "risk"],
|
||||||
|
}
|
||||||
grouped = {}
|
grouped = {}
|
||||||
for item in memory_proposals or []:
|
for item in memory_proposals or []:
|
||||||
label = str(item.get("kind_label") or item.get("kind") or "Insights").strip()
|
label = str(item.get("kind_label") or item.get("kind") or "Insights").strip()
|
||||||
key = label.lower()
|
key = str(item.get("kind") or label).strip().lower()
|
||||||
if key not in grouped:
|
if key not in grouped:
|
||||||
grouped[key] = {"title": label, "items": []}
|
grouped[key] = {
|
||||||
|
"title": label,
|
||||||
|
"key": key,
|
||||||
|
"signal_keys": list(signal_keys_by_kind.get(key, [])),
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
grouped[key]["items"].append(item)
|
grouped[key]["items"].append(item)
|
||||||
return list(grouped.values())
|
return list(grouped.values())
|
||||||
|
|
||||||
@@ -3502,6 +3576,7 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
|
|||||||
"person": person,
|
"person": person,
|
||||||
"workspace_conversation": conversation,
|
"workspace_conversation": conversation,
|
||||||
"directionality": directionality,
|
"directionality": directionality,
|
||||||
|
"overview_rows": _information_overview_rows(conversation),
|
||||||
"commitment_graph_cards": commitment_graph_cards,
|
"commitment_graph_cards": commitment_graph_cards,
|
||||||
"graphs_url": reverse(
|
"graphs_url": reverse(
|
||||||
"ai_workspace_insight_graphs",
|
"ai_workspace_insight_graphs",
|
||||||
|
|||||||
Reference in New Issue
Block a user