Files
GIA/core/templates/partials/ai-workspace-person-widget.html

699 lines
26 KiB
HTML

<div
class="ai-person-widget"
id="ai-person-widget-{{ person.id }}"
data-run-url-template="{% url 'ai_workspace_run' type='widget' person_id=person.id operation='summarise' %}"
data-send-url="{% url 'ai_workspace_send' type='widget' person_id=person.id %}"
data-queue-url="{% url 'ai_workspace_queue' type='widget' person_id=person.id %}"
data-limit="{{ limit }}"
data-can-send="{{ send_state.can_send|yesno:'1,0' }}">
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
<p class="is-size-7">Showing last {{ limit }} messages.</p>
</div>
<div class="notification is-{{ send_state.level }} is-light" style="padding: 0.5rem 0.75rem;">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; flex-wrap: wrap;">
<div><strong>Send:</strong> {{ send_state.text }}</div>
<div class="buttons are-small" style="margin: 0;">
{% if not send_state.can_send %}
<button
type="button"
id="draft-override-top-btn-{{ person.id }}"
class="button is-warning is-light"
onclick="giaWorkspaceEnableSendOverride('{{ person.id }}', 'draft_reply'); return false;">
<span class="icon is-small"><i class="fa-solid fa-triangle-exclamation"></i></span>
<span>Allow Send In Pane</span>
</button>
{% endif %}
</div>
</div>
<div id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
</div>
<form id="ai-op-form-{{ person.id }}" style="margin-bottom: 0.75rem;">
<input type="hidden" name="limit" value="{{ limit }}">
<div class="field">
<label class="label is-small">Notes</label>
<div class="control">
<textarea class="textarea is-small" name="user_notes" rows="2" placeholder="Optional intent/context"></textarea>
</div>
</div>
</form>
<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;">
<ul>
<li id="ai-tab-{{ person.id }}-artifacts">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">Plan</a>
</li>
<li id="ai-tab-{{ person.id }}-summarise">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">Summary</a>
</li>
<li id="ai-tab-{{ person.id }}-draft_reply" class="is-active">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">Draft</a>
</li>
<li id="ai-tab-{{ person.id }}-extract_patterns">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">Patterns</a>
</li>
</ul>
</div>
<div class="is-flex is-align-items-center" style="gap: 0.35rem;">
<span id="ai-cache-indicator-{{ person.id }}" class="tag is-warning is-light is-small" style="display: none;">
Cached
</span>
<button
type="button"
class="button is-small is-ghost"
title="Refresh current tab"
onclick="giaWorkspaceRefresh('{{ person.id }}'); return false;">
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
</button>
</div>
</div>
<div id="ai-stage-{{ person.id }}" style="min-height: 7rem;">
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-warning is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
<span>Plan</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-link is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
<span>Summary</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane">
<button
type="button"
class="button is-primary is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Draft</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-info is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-wave-square"></i></span>
<span>Patterns</span>
</button>
</div>
</div>
</div>
</div>
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
{% if message_rows %}
{% for row in message_rows %}
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
<div class="media-content">
<div
class="content"
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
<div
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
<p class="is-size-7">
{{ row.ts_label }}
{% if row.message.custom_author %}
| {{ row.message.custom_author }}
{% endif %}
</p>
</div>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="has-text-grey">No messages found for this contact.</p>
{% endif %}
</div>
</div>
<style>
@keyframes aiFadeInUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-animate-in {
animation: aiFadeInUp 180ms ease-out;
}
.ai-response-capsule {
transition: all 180ms ease-out;
}
</style>
<script>
(function() {
const personId = "{{ person.id }}";
const canSend = (document.getElementById("ai-person-widget-" + personId)?.dataset.canSend || "0") === "1";
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
const widget = document.getElementById("ai-person-widget-" + personId);
if (!widget) {
return;
}
window.giaWorkspaceState = window.giaWorkspaceState || {};
window.giaWorkspaceCache = window.giaWorkspaceCache || (function() {
try {
// One-time migration flush to avoid stale cached pane HTML from earlier UI schema.
localStorage.removeItem("gia_workspace_cache_v1");
localStorage.removeItem("gia_workspace_cache_v2");
return JSON.parse(localStorage.getItem("gia_workspace_cache_v3") || "{}");
} catch (e) {
return {};
}
})();
function persistCache() {
try {
localStorage.setItem("gia_workspace_cache_v3", JSON.stringify(window.giaWorkspaceCache));
} catch (e) {
// Ignore storage write issues.
}
}
function runUrl(operation) {
const template = widget.dataset.runUrlTemplate || "";
if (template.indexOf("/summarise/") >= 0) {
return template.replace("/summarise/", "/" + operation + "/");
}
return template.replace("summarise", operation);
}
function formData() {
const form = document.getElementById("ai-op-form-" + personId);
const params = new URLSearchParams(new FormData(form));
return params;
}
function cacheKey(operation) {
return personId + "|" + operation + "|" + formData().toString();
}
function applyForceSendState(operation) {
const force = !!(window.giaWorkspaceState[personId] && window.giaWorkspaceState[personId].forceSend);
const forceInput = document.getElementById("draft-send-force-" + personId + "-" + operation);
const sendBtn = document.getElementById("draft-send-btn-" + personId + "-" + operation);
if (forceInput) {
forceInput.value = force ? "1" : "0";
}
if (sendBtn && !canSend) {
sendBtn.disabled = !force;
}
}
function formatUtcLabel(tsMs) {
const ts = Number(tsMs || 0);
if (!ts) {
return "";
}
const dt = new Date(ts);
function pad(value) {
return String(value).padStart(2, "0");
}
return (
dt.getUTCFullYear()
+ "-" + pad(dt.getUTCMonth() + 1)
+ "-" + pad(dt.getUTCDate())
+ " " + pad(dt.getUTCHours())
+ ":" + pad(dt.getUTCMinutes())
+ " UTC"
);
}
function appendOutgoingMessage(tsMs, text, author) {
const host = document.getElementById("ai-message-list-" + personId);
if (!host) {
return;
}
const noMessages = host.querySelector("p.has-text-grey");
if (noMessages) {
noMessages.remove();
}
const article = document.createElement("article");
article.className = "media ai-message-row";
article.dataset.ts = String(Number(tsMs || Date.now()));
article.style.marginBottom = "0.75rem";
const mediaContent = document.createElement("div");
mediaContent.className = "media-content";
const contentWrap = document.createElement("div");
contentWrap.className = "content";
contentWrap.style.marginLeft = "15%";
contentWrap.style.marginRight = "0";
const bubble = document.createElement("div");
bubble.style.marginBottom = "0.25rem";
bubble.style.padding = "0.6rem";
bubble.style.borderRadius = "6px";
bubble.style.border = "1px solid rgba(0, 0, 0, 0.15)";
bubble.style.background = "#f0f7ff";
bubble.style.boxShadow = "none";
const bodyP = document.createElement("p");
bodyP.style.whiteSpace = "pre-wrap";
bodyP.style.marginBottom = "0.35rem";
bodyP.textContent = text || "(no text)";
const metaP = document.createElement("p");
metaP.className = "is-size-7";
metaP.textContent = formatUtcLabel(tsMs);
if (author) {
metaP.textContent += " | " + author;
}
bubble.appendChild(bodyP);
bubble.appendChild(metaP);
contentWrap.appendChild(bubble);
mediaContent.appendChild(contentWrap);
article.appendChild(mediaContent);
host.appendChild(article);
const maxRows = Math.max(5, Math.min(parseInt(widget.dataset.limit || "20", 10) || 20, 200));
const rows = host.querySelectorAll(".ai-message-row");
if (rows.length > maxRows) {
const removeCount = rows.length - maxRows;
for (let i = 0; i < removeCount; i += 1) {
if (rows[i] && rows[i].parentNode) {
rows[i].parentNode.removeChild(rows[i]);
}
}
}
host.scrollTop = host.scrollHeight;
}
function getCacheEntry(operation) {
const key = cacheKey(operation);
const raw = window.giaWorkspaceCache[key];
if (!raw) {
return null;
}
function evict() {
delete window.giaWorkspaceCache[key];
persistCache();
}
if (typeof raw === "string") {
// Backward compatibility: old format has no timestamp; treat as expired.
evict();
return null;
}
if (raw && typeof raw === "object" && typeof raw.html === "string") {
const ts = typeof raw.ts === "number" ? raw.ts : null;
if (!ts) {
evict();
return null;
}
if ((Date.now() - ts) > CACHE_TTL_MS) {
evict();
return null;
}
return { html: raw.html, ts: ts };
}
evict();
return null;
}
function formatCacheAge(ts) {
if (!ts) {
return "Cached";
}
const deltaSec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (deltaSec < 5) return "Cached just now";
if (deltaSec < 60) return "Cached " + deltaSec + "s ago";
if (deltaSec < 3600) return "Cached " + Math.floor(deltaSec / 60) + "m ago";
if (deltaSec < 86400) return "Cached " + Math.floor(deltaSec / 3600) + "h ago";
return "Cached " + Math.floor(deltaSec / 86400) + "d ago";
}
function executeInlineScripts(container) {
if (!container) {
return;
}
const scripts = container.querySelectorAll("script");
scripts.forEach(function(oldScript) {
const newScript = document.createElement("script");
if (oldScript.src) {
newScript.src = oldScript.src;
} else {
newScript.textContent = oldScript.textContent || "";
}
Array.from(oldScript.attributes || []).forEach(function(attr) {
if (attr.name !== "src") {
newScript.setAttribute(attr.name, attr.value);
}
});
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
function setCachedIndicator(show, ts) {
const indicator = document.getElementById("ai-cache-indicator-" + personId);
if (!indicator) {
return;
}
if (show) {
indicator.textContent = formatCacheAge(ts);
}
indicator.style.display = show ? "inline-flex" : "none";
}
function hydrateCachedIfAvailable(operation) {
if (operation === "artifacts") {
return false;
}
const entry = getCacheEntry(operation);
const pane = document.getElementById("ai-pane-" + personId + "-" + operation);
if (!pane) {
return false;
}
if (entry && !pane.dataset.loaded) {
pane.innerHTML = entry.html;
pane.dataset.loaded = "1";
executeInlineScripts(pane);
if (window.htmx) {
window.htmx.process(pane);
}
return true;
}
return false;
}
window.giaWorkspaceShowTab = function(pid, operation) {
if (pid !== personId) {
return;
}
["artifacts", "summarise", "draft_reply", "extract_patterns"].forEach(function(op) {
const tab = document.getElementById("ai-tab-" + personId + "-" + op);
const pane = document.getElementById("ai-pane-" + personId + "-" + op);
if (!tab || !pane) {
return;
}
if (op === operation) {
tab.classList.add("is-active");
pane.style.display = "block";
} else {
tab.classList.remove("is-active");
pane.style.display = "none";
}
});
const hydrated = hydrateCachedIfAvailable(operation);
const entry = operation === "artifacts" ? null : getCacheEntry(operation);
setCachedIndicator(hydrated || !!entry, entry ? entry.ts : null);
window.giaWorkspaceState[personId] = window.giaWorkspaceState[personId] || {};
window.giaWorkspaceState[personId].current = operation;
};
window.giaWorkspaceRun = function(pid, operation, forceRefresh) {
if (pid !== personId) {
return;
}
const cacheAllowed = operation !== "artifacts";
const shell = document.getElementById("ai-response-shell-" + personId);
const pane = document.getElementById("ai-pane-" + personId + "-" + operation);
if (!shell || !pane) {
return;
}
const currentState = window.giaWorkspaceState[personId] || {};
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
window.giaWorkspaceShowTab(personId, operation);
return;
}
window.giaWorkspaceShowTab(personId, operation);
const key = cacheKey(operation);
const entry = getCacheEntry(operation);
if (cacheAllowed && !forceRefresh && entry) {
pane.innerHTML = entry.html;
pane.dataset.loaded = "1";
pane.classList.remove("ai-animate-in");
void pane.offsetWidth;
pane.classList.add("ai-animate-in");
setCachedIndicator(true, entry.ts);
if (window.htmx) {
window.htmx.process(pane);
}
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0);
}
return;
}
setCachedIndicator(false, null);
pane.innerHTML = '<div class="notification is-light ai-animate-in">Loading...</div>';
const url = runUrl(operation) + "?" + formData().toString();
fetch(url, { method: "GET" })
.then(function(resp) { return resp.text(); })
.then(function(html) {
pane.innerHTML = html;
pane.dataset.loaded = "1";
executeInlineScripts(pane);
pane.classList.remove("ai-animate-in");
void pane.offsetWidth;
pane.classList.add("ai-animate-in");
if (cacheAllowed) {
window.giaWorkspaceCache[key] = {
html: html,
ts: Date.now(),
};
persistCache();
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
} else {
setCachedIndicator(false, null);
}
if (window.htmx) {
window.htmx.process(pane);
}
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0);
}
})
.catch(function() {
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
});
};
window.giaWorkspaceRefresh = function(pid) {
if (pid !== personId) {
return;
}
const current = (window.giaWorkspaceState[personId] && window.giaWorkspaceState[personId].current) || "summarise";
window.giaWorkspaceRun(personId, current, true);
};
window.giaWorkspaceUseDraft = function(pid, operation, index) {
if (pid !== personId) {
return;
}
const host = document.getElementById("draft-host-" + personId + "-" + operation);
const optionCard = host ? host.querySelector('.draft-option-card[data-index="' + index + '"]') : null;
const option = optionCard ? optionCard.querySelector(".draft-text") : null;
if (!option) {
return;
}
const cards = host ? host.querySelectorAll(".draft-option-card") : [];
cards.forEach(function(el) { el.classList.remove("is-selected"); });
if (optionCard) {
optionCard.classList.add("is-selected");
}
host.dataset.selected = String(index);
const sendShell = document.getElementById("draft-send-shell-" + personId + "-" + operation);
const hiddenInput = document.getElementById("draft-send-input-" + personId + "-" + operation);
const preview = document.getElementById("draft-send-preview-" + personId + "-" + operation);
if (!sendShell || !hiddenInput || !preview) {
return;
}
hiddenInput.value = option.textContent.trim();
preview.value = option.textContent.trim();
applyForceSendState(operation);
sendShell.classList.remove("ai-animate-in");
void sendShell.offsetWidth;
sendShell.classList.add("ai-animate-in");
};
window.giaWorkspaceEnableSendOverride = function(pid, operation) {
if (pid !== personId) {
return;
}
window.giaWorkspaceState[personId] = window.giaWorkspaceState[personId] || {};
window.giaWorkspaceState[personId].forceSend = true;
applyForceSendState(operation);
if (typeof window.giaEngageSyncSendOverride === "function") {
window.giaEngageSyncSendOverride(personId);
}
const overrideBtn = document.getElementById("draft-override-top-btn-" + personId);
if (overrideBtn) {
overrideBtn.classList.remove("is-warning");
overrideBtn.classList.add("is-success");
const labelNode = overrideBtn.querySelector("span:last-child");
if (labelNode) {
labelNode.textContent = "Override Enabled";
}
}
const statusHost = document.getElementById("draft-top-status-" + personId);
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-success is-light" style="padding: 0.45rem 0.6rem;">Send override enabled for this pane.</div>';
}
};
window.giaWorkspaceQueueSelectedDraft = function(pid) {
if (pid !== personId) {
return;
}
const queueUrl = widget.dataset.queueUrl;
const preview = document.getElementById("draft-send-preview-" + personId + "-draft_reply");
const statusHost = document.getElementById("draft-top-status-" + personId);
const text = preview ? preview.value.trim() : "";
if (!text) {
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-warning is-light" style="padding: 0.45rem 0.6rem;">Select a draft first, then queue it.</div>';
}
return;
}
const payload = new URLSearchParams();
payload.append("draft_text", text);
fetch(queueUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-CSRFToken": "{{ csrf_token }}",
},
body: payload.toString(),
})
.then(function(resp) { return resp.text(); })
.then(function(html) {
if (statusHost) {
statusHost.innerHTML = html;
}
})
.catch(function() {
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
}
});
};
if (typeof window.giaMitigationShowTab !== "function") {
window.giaMitigationShowTab = function(pid, tabName) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"];
names.forEach(function(name) {
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
if (!pane || !tab) {
return;
}
const active = (name === tabName);
pane.style.display = active ? "block" : "none";
tab.classList.toggle("is-active", active);
});
const shell = document.getElementById("mitigation-shell-" + pid);
if (!shell) {
return;
}
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
input.value = tabName;
});
};
}
if (typeof window.giaMitigationToggleEdit !== "function") {
window.giaMitigationToggleEdit = function(button) {
const form = button ? button.closest("form") : null;
if (!form) {
return;
}
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
button.dataset.editState = "edit";
button.textContent = "Save";
button.classList.remove("is-light");
} else {
form.requestSubmit();
}
};
}
if (typeof window.giaEngageSetAction !== "function") {
window.giaEngageSetAction = function(pid, action) {
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) {
actionInput.value = action;
}
};
}
if (typeof window.giaEngageAutoPreview !== "function") {
window.giaEngageAutoPreview = function(pid) {
const form = document.getElementById("engage-form-" + pid);
if (!form) {
return;
}
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
};
}
if (typeof window.giaEngageSelect !== "function") {
window.giaEngageSelect = function(pid, kind, value, node) {
let inputId = "";
if (kind === "share") {
inputId = "engage-share-input-" + pid;
} else if (kind === "framing") {
inputId = "engage-framing-input-" + pid;
}
const input = inputId ? document.getElementById(inputId) : null;
if (input) {
input.value = value;
}
const li = node && node.closest ? node.closest("li") : null;
if (li && li.parentElement) {
Array.from(li.parentElement.children).forEach(function(child) {
child.classList.remove("is-active");
});
li.classList.add("is-active");
}
window.giaEngageAutoPreview(pid);
};
}
window.giaWorkspaceMessageListeners = window.giaWorkspaceMessageListeners || {};
const existingListener = window.giaWorkspaceMessageListeners[personId];
if (existingListener) {
document.body.removeEventListener("gia-message-sent", existingListener);
}
const messageSentListener = function(evt) {
const detail = (evt && evt.detail) ? evt.detail : {};
if (!detail || String(detail.person_id || "") !== personId) {
return;
}
appendOutgoingMessage(
Number(detail.ts || Date.now()),
String(detail.text || ""),
String(detail.author || "BOT")
);
};
document.body.addEventListener("gia-message-sent", messageSentListener);
window.giaWorkspaceMessageListeners[personId] = messageSentListener;
window.giaWorkspaceRun(personId, "artifacts", false);
})();
</script>