Renovate mitigation panels and messages

This commit is contained in:
2026-02-15 21:02:40 +00:00
parent 63af5d234e
commit cc3fff0757
12 changed files with 1518 additions and 236 deletions

View File

@@ -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;">
<span class="tag is-dark">
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
@@ -45,13 +47,32 @@
{% endif %}
{% endif %}
{% if operation == "artifacts" %}
{% 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" %}
{% else %}
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.2rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">No mitigation plan yet. Use the Patterns tab to generate one.</p>
<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 %}
{% 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 %}
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.2rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">No mitigation plan yet. Use the Patterns tab to generate one.</p>
</div>
{% endif %}
</div>
{% endif %}
</details>
{% elif operation == "draft_reply" and draft_replies %}
<div id="draft-host-{{ person.id }}-{{ operation }}" data-selected="0">
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
@@ -177,31 +198,58 @@
{% endif %}
{% 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-grey" style="margin-bottom: 0.35rem;">
Click a signal to highlight related memory proposals and evidence citations.
</p>
<div class="tags">
{% 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 %}
</div>
</article>
{% endif %}
{% 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-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 %}
<div class="columns is-multiline" style="margin: 0 -0.25rem;">
{% for group in memory_proposal_groups %}
<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>
<ul style="margin: 0 0 0.25rem 1.1rem;">
{% for proposal in group.items %}
<li class="is-size-7" style="margin-bottom: 0.22rem; white-space: pre-wrap;">
{{ proposal.content }}
</li>
{% endfor %}
</ul>
<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;">
{% for proposal in group.items %}
<li class="is-size-7" style="margin-bottom: 0.22rem; white-space: pre-wrap;">
{{ proposal.content }}
</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
</div>
@@ -216,12 +264,18 @@
{% endif %}
{% 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-grey" style="margin-bottom: 0.35rem;">
These are source messages used as evidence for this AI result.
</p>
{% if citation_rows %}
<div class="content is-small" style="margin-bottom: 0;">
{% 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>
{% if row.ts_label %}
<span class="has-text-grey">{{ row.ts_label }}</span>
@@ -283,16 +337,231 @@
</form>
</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">
Plan editing is consolidated in the <strong>Plan</strong> tab.
</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>
{% endif %}
{% endif %}
</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>
.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 {
border-color: rgba(54, 54, 54, 0.85) !important;
border-width: 2px !important;

View File

@@ -1,13 +1,13 @@
<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>
<div class="is-flex is-justify-content-space-between is-align-items-start mitigation-header" style="gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="mitigation-header-main">
<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>
{% if plan.objective %}
<p class="is-size-7">{{ plan.objective }}</p>
{% endif %}
</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">Created {{ plan.created_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="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;">
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">RULES</p>
<button
@@ -106,36 +106,51 @@
hx-swap="outerHTML">Delete All</button>
</div>
{% 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;">
<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>
<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;">
<form
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-target="#mitigation-shell-{{ person.id }}"
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' }}">
<div class="buttons are-small" style="margin: 0;">
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
<button
type="button"
class="button is-danger is-light"
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-vals='{"active_tab":"plan_board"}'
hx-confirm="Delete this rule?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete</button>
<div class="mitigation-artifact-headline">
<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
type="button"
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-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-confirm="Delete this rule?"
hx-target="#mitigation-shell-{{ person.id }}"
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>
</form>
</article>
@@ -144,11 +159,11 @@
<p class="is-size-7 has-text-grey">No rules yet.</p>
</article>
{% endfor %}
</article>
</div>
</div>
<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;">
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">GAMES</p>
<button
@@ -162,36 +177,51 @@
hx-swap="outerHTML">Delete All</button>
</div>
{% 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;">
<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>
<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;">
<form
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-target="#mitigation-shell-{{ person.id }}"
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' }}">
<div class="buttons are-small" style="margin: 0;">
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
<button
type="button"
class="button is-danger is-light"
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-vals='{"active_tab":"plan_board"}'
hx-confirm="Delete this game?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete</button>
<div class="mitigation-artifact-headline">
<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
type="button"
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-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-confirm="Delete this game?"
hx-target="#mitigation-shell-{{ person.id }}"
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>
</form>
</article>
@@ -200,7 +230,7 @@
<p class="is-size-7 has-text-grey">No games yet.</p>
</article>
{% endfor %}
</article>
</div>
</div>
</div>
</div>
@@ -615,6 +645,110 @@
45% { background-color: rgba(236, 246, 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 {
animation: engagePreviewPulse 850ms ease-in-out 1;
}
@@ -675,15 +809,24 @@
window.giaMitigationToggleEdit = function(button) {
const form = button.closest("form");
if (!form) return;
const card = form.closest(".mitigation-artifact-card");
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
toggles.forEach(function(field) {
field.removeAttribute("disabled");
});
if (card) {
card.classList.add("is-editing");
}
button.dataset.editState = "edit";
button.textContent = "Save";
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);
} else {
form.requestSubmit();

View File

@@ -112,33 +112,37 @@
<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="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>
<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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</li>
</ul>
@@ -179,6 +183,41 @@
.ai-response-capsule {
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>
<script>
@@ -639,15 +678,24 @@
if (!form) {
return;
}
const card = form.closest(".mitigation-artifact-card");
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
toggles.forEach(function(field) {
field.removeAttribute("disabled");
});
if (card) {
card.classList.add("is-editing");
}
button.dataset.editState = "edit";
button.textContent = "Save";
button.classList.remove("is-light");
button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
} else {
form.requestSubmit();
}

View File

@@ -13,7 +13,7 @@
{{ service|title }} · {{ identifier }}
</p>
</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">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Drafts</span>
@@ -34,6 +34,12 @@
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>AI Workspace</span>
</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>
@@ -104,6 +110,24 @@
</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
id="{{ panel_id }}-thread"
class="compose-thread"
@@ -122,23 +146,14 @@
{% for msg in serialized_messages %}
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
{% if msg.gap_fragments %}
<div class="compose-gap-artifacts">
{% for frag in msg.gap_fragments %}
<article class="compose-artifact compose-artifact-gap">
<p class="compose-artifact-head">
<span class="icon is-small"><i class="fa-solid fa-hourglass-half"></i></span>
<span>{{ frag.focus }} · {{ frag.lag }}</span>
<span class="compose-artifact-score">Score {{ frag.score }}</span>
</p>
{% if frag.calculation %}
<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>
{% with gap=msg.gap_fragments.0 %}
<p
class="compose-latency-chip"
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-regular fa-clock"></i></span>
<span class="compose-latency-val">{{ gap.lag|default:"-" }}</span>
</p>
{% endwith %}
{% endif %}
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
{% if msg.image_urls %}
@@ -171,21 +186,6 @@
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
</p>
</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>
{% empty %}
<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="render_mode" value="{{ render_mode }}">
<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_confirm" value="0">
{% if person %}
@@ -234,6 +235,7 @@
<style>
#{{ panel_id }}.compose-shell {
position: relative;
overflow: visible;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: none;
@@ -263,6 +265,57 @@
#{{ panel_id }} .compose-row.is-out {
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 {
max-width: min(85%, 46rem);
border-radius: 8px;
@@ -319,6 +372,35 @@
}
#{{ panel_id }} .compose-artifact.compose-artifact-gap {
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 {
margin: 0;
@@ -399,6 +481,46 @@
margin-top: 0.55rem;
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 {
margin: 0;
font-size: 0.76rem;
@@ -413,17 +535,19 @@
}
#{{ panel_id }} .compose-ai-popover {
position: absolute;
top: 4.2rem;
right: 0.7rem;
width: min(34rem, calc(100% - 1.4rem));
z-index: 25;
top: 0;
left: 0;
width: min(40rem, calc(100% - 1rem));
margin-top: 0;
z-index: 35;
overflow: auto;
}
#{{ panel_id }} .compose-ai-popover-backdrop {
position: absolute;
inset: 0.45rem;
inset: 0;
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));
z-index: 24;
z-index: 34;
pointer-events: auto;
opacity: 1;
transition: opacity 140ms ease-out;
@@ -477,18 +601,22 @@
height: auto;
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
line-height: 1.35;
padding: 0.58rem 0.62rem;
}
#{{ panel_id }} .compose-draft-tone {
margin: 0 0 0.2rem 0;
display: block;
width: 100%;
margin: 0 0 0.28rem 0;
color: #6e7782;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
#{{ panel_id }} .compose-draft-text {
margin: 0;
@@ -502,6 +630,21 @@
#{{ panel_id }} .buttons {
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 {
position: relative;
overflow: visible;
@@ -575,18 +718,22 @@
}
#{{ panel_id }} .compose-qi-row-head {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.2rem;
flex-wrap: wrap;
}
#{{ panel_id }} .compose-qi-row-label {
display: inline-flex;
align-items: center;
gap: 0.32rem;
min-width: 0;
flex: 1 1 auto;
font-size: 0.74rem;
font-weight: 600;
overflow-wrap: anywhere;
word-break: break-word;
}
#{{ panel_id }} .compose-qi-row-meta {
display: inline-flex;
@@ -594,6 +741,20 @@
gap: 0.44rem;
font-size: 0.68rem;
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 {
display: flex;
@@ -672,9 +833,20 @@
display: none;
}
#{{ panel_id }} .compose-ai-popover {
left: 0.7rem;
right: 0.7rem;
width: auto;
width: calc(100% - 1rem);
}
#{{ 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>
@@ -695,6 +867,7 @@
const statusBox = document.getElementById(panelId + "-status");
const typingNode = document.getElementById(panelId + "-typing");
const glanceNode = document.getElementById(panelId + "-glance");
const popover = document.getElementById(panelId + "-popover");
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
const csrfToken = "{{ csrf_token }}";
@@ -713,6 +886,9 @@
if (previousState && previousState.docClickHandler) {
document.removeEventListener("mousedown", previousState.docClickHandler);
}
if (previousState && previousState.resizeHandler) {
window.removeEventListener("resize", previousState.resizeHandler);
}
const panelState = {
timer: null,
polling: false,
@@ -724,12 +900,68 @@
window.giaComposePanels[panelId] = panelState;
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 parsed = parseInt(value || "0", 10);
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 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 () {
textarea.style.height = "auto";
@@ -838,93 +1070,134 @@
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 row = document.createElement("div");
const outgoing = !!msg.outgoing;
row.className = "compose-row " + (outgoing ? "is-out" : "is-in");
row.dataset.ts = String(msg.ts || 0);
const appendGapArtifacts = function (fragments) {
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);
row.dataset.minute = minuteBucketFromTs(msg.ts || 0);
appendLatencyChip(row, msg);
const bubble = document.createElement("article");
bubble.className = "compose-bubble " + (outgoing ? "is-out" : "is-in");
@@ -963,13 +1236,51 @@
bubble.appendChild(meta);
row.appendChild(bubble);
appendMetricArtifacts(msg.metric_fragments);
const empty = thread.querySelector(".compose-empty");
if (empty) {
empty.remove();
}
thread.appendChild(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) {
@@ -980,6 +1291,7 @@
});
thread.dataset.lastTs = String(lastTs);
if ((messages || []).length > 0) {
applyMinuteGrouping();
scrollToBottom(shouldStick);
}
};
@@ -1106,6 +1418,7 @@
} catch (err) {
// Ignore invalid initial typing state payload.
}
applyMinuteGrouping();
const setStatus = function (message, level) {
if (!statusBox) {
@@ -1132,8 +1445,15 @@
};
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) {
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;
setActiveTrigger(kind);
positionPopover(kind);
return active;
};
@@ -1670,6 +1991,13 @@
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);
textarea.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !event.shiftKey) {
@@ -1686,13 +2014,22 @@
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);
};
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
panelState.sendResultHandler = function (event) {
const detail = (event && event.detail) || {};
const sourcePanelId = String(detail.panel_id || "");
if (!sourcePanelId || sourcePanelId !== panelId) {
return;
}
const ok = !!detail.ok;
if (ok) {
flashCompose("is-send-success");

View 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>

View File

@@ -10,4 +10,14 @@
{% else %}
<a class="navbar-item is-disabled">No contacts found.</a>
{% 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 %}