Improve chat experience and begin search implementation

This commit is contained in:
2026-02-15 17:32:26 +00:00
parent 6612274ab9
commit a94bbff655
21 changed files with 3081 additions and 179 deletions

View File

@@ -397,6 +397,12 @@ def get_link_qr(service: str, device_name: str):
if cached:
return cached
if service_key == "whatsapp":
raise RuntimeError(
"Neonize has not provided a pairing QR yet. "
"Ensure UR is running with WHATSAPP_ENABLED=true and retry."
)
token = secrets.token_urlsafe(24)
uri = f"gia://{service_key}/link?device={device}&token={token}"
update_runtime_state(

162
core/realtime/compose_ws.py Normal file
View File

@@ -0,0 +1,162 @@
import asyncio
import json
import time
from datetime import datetime, timezone as dt_timezone
from urllib.parse import parse_qs
from asgiref.sync import sync_to_async
from django.core import signing
from core.models import ChatSession, Message, PersonIdentifier
from core.views.compose import COMPOSE_WS_TOKEN_SALT
def _safe_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
def _fmt_ts(ts_value):
try:
dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
return dt.strftime("%H:%M")
except Exception:
return str(ts_value or "")
def _serialize_message(msg):
author = str(msg.custom_author or "").strip()
return {
"id": str(msg.id),
"ts": int(msg.ts or 0),
"display_ts": _fmt_ts(msg.ts),
"text": str(msg.text or ""),
"author": author,
"outgoing": author.upper() in {"USER", "BOT"},
}
def _load_since(user_id, service, identifier, person_id, after_ts, limit):
person_identifier = None
if person_id:
person_identifier = (
PersonIdentifier.objects.filter(
user_id=user_id,
person_id=person_id,
service=service,
).first()
or PersonIdentifier.objects.filter(
user_id=user_id,
person_id=person_id,
).first()
)
if person_identifier is None and identifier:
person_identifier = PersonIdentifier.objects.filter(
user_id=user_id,
service=service,
identifier=identifier,
).first()
if person_identifier is None:
return {"messages": [], "last_ts": after_ts}
session = ChatSession.objects.filter(
user_id=user_id,
identifier=person_identifier,
).first()
if session is None:
return {"messages": [], "last_ts": after_ts}
qs = Message.objects.filter(
user_id=user_id,
session=session,
).order_by("ts")
if after_ts > 0:
qs = qs.filter(ts__gt=after_ts)
rows = list(qs[: max(10, min(limit, 200))])
newest = (
Message.objects.filter(user_id=user_id, session=session)
.order_by("-ts")
.values_list("ts", flat=True)
.first()
)
return {
"messages": [_serialize_message(row) for row in rows],
"last_ts": int(newest or after_ts or 0),
}
async def compose_ws_application(scope, receive, send):
if scope.get("type") != "websocket":
return
query_string = (scope.get("query_string") or b"").decode("utf-8", errors="ignore")
params = parse_qs(query_string)
token = (params.get("token") or [""])[0]
try:
payload = signing.loads(token, salt=COMPOSE_WS_TOKEN_SALT)
except Exception:
await send({"type": "websocket.close", "code": 4401})
return
if _safe_int(payload.get("exp")) < int(time.time()):
await send({"type": "websocket.close", "code": 4401})
return
user_id = _safe_int(payload.get("u"))
service = str(payload.get("s") or "").strip()
identifier = str(payload.get("i") or "").strip()
person_id = str(payload.get("p") or "").strip()
if user_id <= 0 or (not identifier and not person_id):
await send({"type": "websocket.close", "code": 4401})
return
await send({"type": "websocket.accept"})
last_ts = 0
limit = 100
while True:
event = None
try:
event = await asyncio.wait_for(receive(), timeout=1.2)
except asyncio.TimeoutError:
event = None
if event and event.get("type") == "websocket.disconnect":
break
if event and event.get("type") == "websocket.receive":
try:
body = json.loads(event.get("text") or "{}")
except Exception:
body = {}
if body.get("kind") == "sync":
last_ts = max(last_ts, _safe_int(body.get("last_ts"), 0))
payload = await sync_to_async(_load_since)(
user_id=user_id,
service=service,
identifier=identifier,
person_id=person_id,
after_ts=last_ts,
limit=limit,
)
messages = payload.get("messages") or []
latest = _safe_int(payload.get("last_ts"), last_ts)
if messages:
last_ts = max(last_ts, latest)
await send(
{
"type": "websocket.send",
"text": json.dumps(
{
"messages": messages,
"last_ts": last_ts,
}
),
}
)
else:
last_ts = max(last_ts, latest)

View File

@@ -212,6 +212,43 @@
z-index: 39 !important;
}
.osint-table-shell {
border: 1px solid rgba(127, 127, 127, 0.2);
border-radius: 14px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.45);
}
.osint-table-toolbar {
margin-bottom: 0.75rem;
}
.osint-results-table-wrap {
border-radius: 10px;
overflow: auto;
}
.osint-results-table th {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.osint-sort-link {
color: inherit;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.osint-sort-link:hover {
color: #3273dc;
}
.osint-search-form .button.is-fullwidth {
width: 100%;
}
</style>
</head>
<body>
@@ -235,6 +272,9 @@
Home
</a>
{% if user.is_authenticated %}
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
Search
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div class="columns is-centered">
<div class="column is-11-tablet is-10-desktop is-9-widescreen">
<div class="is-flex is-justify-content-space-between is-align-items-center">
<div>
<h1 class="title is-4">Search</h1>
<p class="subtitle is-6">
Search across OSINT objects with sortable, paginated results.
</p>
</div>
<div>
<button
class="button is-light"
hx-get="{{ search_widget_url }}"
hx-target="#widgets-here"
hx-swap="beforeend"
onclick="document.getElementById('widgets-here').style.display = 'block';">
Open Widget
</button>
</div>
</div>
{% include "partials/osint/search-panel.html" %}
</div>
</div>
{% endblock %}

View File

@@ -14,6 +14,18 @@
</p>
</div>
<div class="buttons are-small" 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>
</button>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="summary">
<span class="icon is-small"><i class="fa-solid fa-list"></i></span>
<span>Summary</span>
</button>
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="engage">
<span class="icon is-small"><i class="fa-solid fa-handshake"></i></span>
<span>Engage</span>
</button>
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>AI Workspace</span>
@@ -25,6 +37,46 @@
{% include "partials/compose-send-status.html" %}
</div>
<div id="{{ panel_id }}-popover" class="compose-ai-popover is-hidden">
<div class="compose-ai-card" data-kind="drafts">
<p class="compose-ai-title">Draft Suggestions</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
</div>
<div class="compose-ai-card" data-kind="summary">
<p class="compose-ai-title">Conversation Summary</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
</div>
<div class="compose-ai-card" data-kind="engage">
<p class="compose-ai-title">Quick Engage (Shared Framing)</p>
<div class="compose-ai-loading">
<div class="compose-ai-skel"></div>
<div class="compose-ai-skel"></div>
</div>
<div class="compose-ai-content"></div>
<div class="compose-ai-safety">
<label class="checkbox is-size-7">
<input type="checkbox" class="engage-arm"> Arm Send
</label>
<label class="checkbox is-size-7">
<input type="checkbox" class="engage-confirm"> Confirm Share To Other Party
</label>
<button type="button" class="button is-link is-light is-small engage-send-btn" disabled>
Send Engage
</button>
</div>
</div>
</div>
<div
id="{{ panel_id }}-thread"
class="compose-thread"
@@ -33,7 +85,12 @@
data-identifier="{{ identifier }}"
data-person="{% if person %}{{ person.id }}{% endif %}"
data-limit="{{ limit }}"
data-last-ts="{{ last_ts }}">
data-last-ts="{{ last_ts }}"
data-ws-url="{{ compose_ws_url }}"
data-drafts-url="{{ compose_drafts_url }}"
data-summary-url="{{ compose_summary_url }}"
data-engage-preview-url="{{ compose_engage_preview_url }}"
data-engage-send-url="{{ compose_engage_send_url }}">
{% for msg in serialized_messages %}
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
@@ -59,9 +116,19 @@
<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="failsafe_arm" value="0">
<input type="hidden" name="failsafe_confirm" value="0">
{% if person %}
<input type="hidden" name="person" value="{{ person.id }}">
{% endif %}
<div class="compose-send-safety">
<label class="checkbox is-size-7">
<input type="checkbox" class="manual-arm"> Arm Send
</label>
<label class="checkbox is-size-7">
<input type="checkbox" class="manual-confirm"> Confirm Intent
</label>
</div>
<div class="compose-composer-capsule">
<textarea
id="{{ panel_id }}-textarea"
@@ -69,7 +136,7 @@
name="text"
rows="1"
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
<button class="button is-link is-light compose-send-btn" type="submit">
<button class="button is-link is-light compose-send-btn" type="submit" disabled>
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span>Send</span>
</button>
@@ -79,6 +146,7 @@
<style>
#{{ panel_id }}.compose-shell {
position: relative;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
box-shadow: none;
@@ -164,10 +232,87 @@
border-radius: 8px;
margin: 0;
}
#{{ panel_id }} .compose-send-btn[disabled] {
opacity: 0.55;
}
#{{ panel_id }} .compose-send-safety {
display: flex;
gap: 0.85rem;
flex-wrap: wrap;
margin-bottom: 0.45rem;
color: #505050;
}
#{{ panel_id }} .compose-status {
margin-top: 0.55rem;
min-height: 1.1rem;
}
#{{ panel_id }} .compose-ai-popover {
position: absolute;
top: 4.2rem;
right: 0.7rem;
width: min(34rem, calc(100% - 1.4rem));
z-index: 25;
}
#{{ panel_id }} .compose-ai-popover.is-hidden {
display: none;
}
#{{ panel_id }} .compose-ai-card {
display: none;
border: 1px solid rgba(0, 0, 0, 0.16);
border-radius: 8px;
background: #fff;
padding: 0.65rem;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
}
#{{ panel_id }} .compose-ai-card.is-active {
display: block;
animation: composeFadeIn 160ms ease-out;
}
#{{ panel_id }} .compose-ai-title {
font-weight: 600;
margin-bottom: 0.45rem;
}
#{{ panel_id }} .compose-ai-loading.is-hidden {
display: none;
}
#{{ panel_id }} .compose-ai-skel {
height: 0.7rem;
border-radius: 999px;
margin-bottom: 0.4rem;
background: linear-gradient(90deg, rgba(233, 236, 239, 0.8), rgba(210, 214, 218, 0.95), rgba(233, 236, 239, 0.8));
background-size: 200% 100%;
animation: composePulse 1s ease-in-out infinite;
}
#{{ panel_id }} .compose-ai-skel:last-child {
margin-bottom: 0;
}
#{{ panel_id }} .compose-ai-content {
white-space: pre-wrap;
}
#{{ panel_id }} .compose-draft-option {
width: 100%;
text-align: left;
margin-bottom: 0.45rem;
border-radius: 8px;
}
#{{ panel_id }} .compose-draft-option:last-child {
margin-bottom: 0;
}
#{{ panel_id }} .compose-ai-safety {
margin-top: 0.55rem;
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
align-items: center;
}
@keyframes composePulse {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
@keyframes composeFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
#{{ panel_id }} .compose-thread {
max-height: 52vh;
@@ -175,6 +320,11 @@
#{{ panel_id }} .compose-send-btn span:last-child {
display: none;
}
#{{ panel_id }} .compose-ai-popover {
left: 0.7rem;
right: 0.7rem;
width: auto;
}
}
</style>
@@ -192,6 +342,10 @@
return;
}
const statusBox = document.getElementById(panelId + "-status");
const popover = document.getElementById(panelId + "-popover");
const csrfToken = "{{ csrf_token }}";
window.giaComposePanels = window.giaComposePanels || {};
const previousState = window.giaComposePanels[panelId];
if (previousState && previousState.timer) {
@@ -200,7 +354,17 @@
if (previousState && previousState.eventHandler) {
document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
}
const panelState = { timer: null, polling: false };
if (previousState && previousState.docClickHandler) {
document.removeEventListener("mousedown", previousState.docClickHandler);
}
const panelState = {
timer: null,
polling: false,
socket: null,
websocketReady: false,
activePanel: null,
engageToken: ""
};
window.giaComposePanels[panelId] = panelState;
const toInt = function (value) {
@@ -259,8 +423,20 @@
thread.appendChild(row);
};
const appendMessages = function (messages, forceScroll) {
const shouldStick = nearBottom() || forceScroll;
(messages || []).forEach(function (msg) {
appendBubble(msg);
lastTs = Math.max(lastTs, toInt(msg.ts));
});
thread.dataset.lastTs = String(lastTs);
if ((messages || []).length > 0) {
scrollToBottom(shouldStick);
}
};
const poll = async function (forceScroll) {
if (panelState.polling) {
if (panelState.polling || panelState.websocketReady) {
return;
}
panelState.polling = true;
@@ -273,28 +449,19 @@
}
params.set("limit", thread.dataset.limit || "60");
params.set("after_ts", String(lastTs));
const url = thread.dataset.pollUrl + "?" + params.toString();
const response = await fetch(url, {
const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" },
headers: { Accept: "application/json" }
});
if (!response.ok) {
return;
}
const payload = await response.json();
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const shouldStick = nearBottom() || forceScroll;
messages.forEach(function (msg) {
appendBubble(msg);
lastTs = Math.max(lastTs, toInt(msg.ts));
});
appendMessages(payload.messages || [], forceScroll);
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
}
thread.dataset.lastTs = String(lastTs);
if (messages.length > 0) {
scrollToBottom(shouldStick);
thread.dataset.lastTs = String(lastTs);
}
} catch (err) {
console.debug("compose poll error", err);
@@ -303,9 +470,324 @@
}
};
const setupWebSocket = function () {
const wsPath = thread.dataset.wsUrl || "";
if (!wsPath || !window.WebSocket) {
return;
}
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
const socketUrl = protocol + window.location.host + wsPath;
try {
const socket = new WebSocket(socketUrl);
panelState.socket = socket;
socket.onopen = function () {
panelState.websocketReady = true;
try {
socket.send(JSON.stringify({ kind: "sync", last_ts: lastTs }));
} catch (err) {
// Ignore.
}
};
socket.onmessage = function (event) {
try {
const payload = JSON.parse(event.data || "{}");
appendMessages(payload.messages || [], false);
if (payload.last_ts !== undefined && payload.last_ts !== null) {
lastTs = Math.max(lastTs, toInt(payload.last_ts));
thread.dataset.lastTs = String(lastTs);
}
} catch (err) {
console.debug("compose websocket payload error", err);
}
};
socket.onclose = function () {
panelState.websocketReady = false;
};
socket.onerror = function () {
panelState.websocketReady = false;
};
} catch (err) {
panelState.websocketReady = false;
}
};
const manualArm = form.querySelector(".manual-arm");
const manualConfirm = form.querySelector(".manual-confirm");
const armInput = form.querySelector("input[name='failsafe_arm']");
const confirmInput = form.querySelector("input[name='failsafe_confirm']");
const sendButton = form.querySelector(".compose-send-btn");
const updateManualSafety = function () {
const arm = !!(manualArm && manualArm.checked);
const confirm = !!(manualConfirm && manualConfirm.checked);
if (armInput) {
armInput.value = arm ? "1" : "0";
}
if (confirmInput) {
confirmInput.value = confirm ? "1" : "0";
}
if (sendButton) {
sendButton.disabled = !(arm && confirm);
}
};
if (manualArm) {
manualArm.addEventListener("change", updateManualSafety);
}
if (manualConfirm) {
manualConfirm.addEventListener("change", updateManualSafety);
}
updateManualSafety();
const setStatus = function (message, level) {
if (!statusBox) {
return;
}
if (!message) {
statusBox.innerHTML = "";
return;
}
statusBox.innerHTML = '<article class="notification is-' + (level || "info") + ' is-light" style="padding:0.45rem 0.65rem; margin:0;">' + message + "</article>";
};
const hideAllCards = function () {
if (!popover) {
return;
}
popover.classList.add("is-hidden");
popover.querySelectorAll(".compose-ai-card").forEach(function (card) {
card.classList.remove("is-active");
});
panelState.activePanel = null;
};
const showCard = function (kind) {
if (!popover) {
return null;
}
popover.classList.remove("is-hidden");
let active = null;
popover.querySelectorAll(".compose-ai-card").forEach(function (card) {
const isActive = card.dataset.kind === kind;
card.classList.toggle("is-active", isActive);
if (isActive) {
active = card;
}
});
panelState.activePanel = kind;
return active;
};
const queryParams = function () {
const params = new URLSearchParams();
params.set("service", thread.dataset.service || "");
params.set("identifier", thread.dataset.identifier || "");
if (thread.dataset.person) {
params.set("person", thread.dataset.person);
}
params.set("limit", thread.dataset.limit || "60");
return params;
};
const setCardLoading = function (card, loading) {
const loadingNode = card.querySelector(".compose-ai-loading");
const contentNode = card.querySelector(".compose-ai-content");
if (loadingNode) {
loadingNode.classList.toggle("is-hidden", !loading);
}
if (contentNode && loading) {
contentNode.innerHTML = "";
}
};
const loadDrafts = async function () {
const card = showCard("drafts");
if (!card) {
return;
}
setCardLoading(card, true);
try {
const response = await fetch(thread.dataset.draftsUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load drafts.";
return;
}
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
const container = card.querySelector(".compose-ai-content");
container.innerHTML = "";
drafts.forEach(function (item) {
const button = document.createElement("button");
button.type = "button";
button.className = "button is-light compose-draft-option";
const strong = document.createElement("strong");
strong.textContent = String(item.label || "Option") + ": ";
const span = document.createElement("span");
span.textContent = String(item.text || "");
button.appendChild(strong);
button.appendChild(span);
button.addEventListener("click", function () {
textarea.value = String(item.text || "");
autosize();
textarea.focus();
hideAllCards();
});
container.appendChild(button);
});
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = "Failed to load drafts.";
}
};
const loadSummary = async function () {
const card = showCard("summary");
if (!card) {
return;
}
setCardLoading(card, true);
try {
const response = await fetch(thread.dataset.summaryUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load summary.";
return;
}
card.querySelector(".compose-ai-content").textContent = String(payload.summary || "");
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = "Failed to load summary.";
}
};
const bindEngageControls = function (card) {
const arm = card.querySelector(".engage-arm");
const confirm = card.querySelector(".engage-confirm");
const send = card.querySelector(".engage-send-btn");
const sync = function () {
send.disabled = !(arm.checked && confirm.checked && panelState.engageToken);
};
arm.addEventListener("change", sync);
confirm.addEventListener("change", sync);
send.addEventListener("click", async function () {
if (!panelState.engageToken) {
return;
}
const formData = new URLSearchParams();
formData.set("service", thread.dataset.service || "");
formData.set("identifier", thread.dataset.identifier || "");
if (thread.dataset.person) {
formData.set("person", thread.dataset.person);
}
formData.set("engage_token", panelState.engageToken);
formData.set("failsafe_arm", arm.checked ? "1" : "0");
formData.set("failsafe_confirm", confirm.checked ? "1" : "0");
try {
const response = await fetch(thread.dataset.engageSendUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRFToken": csrfToken,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
body: formData.toString()
});
const payload = await response.json();
if (!payload.ok) {
setStatus(payload.error || "Engage send failed.", "danger");
return;
}
setStatus(payload.message || "Shared engage sent.", "success");
hideAllCards();
poll(true);
} catch (err) {
setStatus("Engage send failed.", "danger");
}
});
};
const loadEngage = async function () {
const card = showCard("engage");
if (!card) {
return;
}
setCardLoading(card, true);
panelState.engageToken = "";
const sendBtn = card.querySelector(".engage-send-btn");
const arm = card.querySelector(".engage-arm");
const confirm = card.querySelector(".engage-confirm");
arm.checked = false;
confirm.checked = false;
sendBtn.disabled = true;
try {
const response = await fetch(thread.dataset.engagePreviewUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load engage preview.";
return;
}
panelState.engageToken = String(payload.token || "");
let text = String(payload.preview || "");
if (payload.artifact) {
text = text + "\n\nSource: " + String(payload.artifact);
}
card.querySelector(".compose-ai-content").textContent = text;
sendBtn.disabled = true;
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent = "Failed to load engage preview.";
}
if (!card.dataset.bound) {
bindEngageControls(card);
card.dataset.bound = "1";
}
};
panel.querySelectorAll(".js-ai-trigger").forEach(function (button) {
button.addEventListener("click", function () {
const kind = button.dataset.kind;
if (panelState.activePanel === kind) {
hideAllCards();
return;
}
if (kind === "drafts") {
loadDrafts();
} else if (kind === "summary") {
loadSummary();
} else if (kind === "engage") {
loadEngage();
}
});
});
panelState.docClickHandler = function (event) {
if (!panel.contains(event.target)) {
hideAllCards();
}
};
document.addEventListener("mousedown", panelState.docClickHandler);
textarea.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (sendButton && sendButton.disabled) {
setStatus("Enable both send safety switches before sending.", "warning");
return;
}
form.requestSubmit();
}
});
@@ -325,17 +807,23 @@
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
scrollToBottom(true);
setupWebSocket();
panelState.timer = setInterval(function () {
if (!document.getElementById(panelId)) {
clearInterval(panelState.timer);
document.body.removeEventListener(
"composeMessageSent",
panelState.eventHandler
);
document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
document.removeEventListener("mousedown", panelState.docClickHandler);
if (panelState.socket) {
try {
panelState.socket.close();
} catch (err) {
// Ignore.
}
}
delete window.giaComposePanels[panelId];
return;
}
poll(false);
}, 1800);
}, 4000);
})();
</script>

View File

@@ -0,0 +1,225 @@
{% include 'mixins/partials/notify.html' %}
<div
id="{{ osint_table_id }}"
class="osint-table-shell"
hx-get="{{ osint_refresh_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML"
{% if osint_event_name %}hx-trigger="{{ osint_event_name }} from:body"{% endif %}>
{% if osint_show_search %}
<form
method="get"
action="{{ osint_search_url }}"
class="osint-table-toolbar"
hx-get="{{ osint_search_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
<div class="field has-addons is-flex-wrap-wrap">
<div class="control is-expanded" style="min-width: 14rem;">
<input
class="input"
type="text"
name="q"
value="{{ osint_search_query }}"
placeholder="Search {{ osint_title|lower }}...">
</div>
<div class="control">
<div class="select">
<select name="field">
{% for field in osint_search_fields %}
<option
value="{{ field.value }}"
{% if field.value == osint_search_field %}selected{% endif %}>
{{ field.label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-link is-light" type="submit">
Search
</button>
</div>
<div class="control">
<a class="button is-light" href="{{ osint_search_url }}">
Reset
</a>
</div>
</div>
</form>
{% endif %}
<div class="table-container osint-results-table-wrap">
<table class="table is-fullwidth is-hoverable osint-results-table">
<thead>
<tr>
{% for column in osint_columns %}
<th>
{% if column.sortable %}
<a
class="osint-sort-link"
href="{{ column.sort_url }}"
hx-get="{{ column.sort_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
<span>{{ column.label }}</span>
<span class="icon is-small">
{% if column.is_sorted and column.is_desc %}
<i class="fa-solid fa-sort-down"></i>
{% elif column.is_sorted %}
<i class="fa-solid fa-sort-up"></i>
{% else %}
<i class="fa-solid fa-sort"></i>
{% endif %}
</span>
</a>
{% else %}
{{ column.label }}
{% endif %}
</th>
{% endfor %}
{% if osint_show_actions %}<th>Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for row in osint_rows %}
<tr>
{% for cell in row.cells %}
<td>
{% if cell.kind == "id_copy" %}
<a
class="button is-small has-text-grey"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ cell.value }}');">
<span class="icon">
<i class="fa-solid fa-copy"></i>
</span>
<span>{{ cell.value }}</span>
</a>
{% elif cell.kind == "bool" %}
{% if cell.value %}
<span class="icon has-text-success">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon has-text-grey">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
{% elif cell.kind == "datetime" %}
{% if cell.value %}
{{ cell.value|date:"M j, Y P" }}
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
{% else %}
{% if cell.value or cell.value == 0 %}
{{ cell.value }}
{% else %}
<span class="has-text-grey">-</span>
{% endif %}
{% endif %}
</td>
{% endfor %}
{% if osint_show_actions %}
<td>
<div class="buttons are-small">
{% for action in row.actions %}
{% if action.mode == "hx-get" %}
<button
class="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ action.url }}"
hx-target="{{ action.target }}"
hx-swap="innerHTML"
title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>
</button>
{% elif action.mode == "hx-delete" %}
<button
class="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{{ action.url }}"
hx-target="{{ action.target }}"
hx-swap="innerHTML"
{% if action.confirm %}hx-confirm="{{ action.confirm }}"{% endif %}
title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>
</button>
{% elif action.mode == "link" %}
<a class="button" href="{{ action.url }}" title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>
</a>
{% endif %}
{% endfor %}
</div>
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if osint_show_actions %}{{ osint_columns|length|add:'1' }}{% else %}{{ osint_columns|length }}{% endif %}">
<p class="has-text-grey">No results found.</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if osint_pagination.enabled %}
<nav class="pagination is-small" role="navigation" aria-label="pagination">
{% if osint_pagination.has_previous %}
<a
class="pagination-previous"
href="{{ osint_pagination.previous_url }}"
hx-get="{{ osint_pagination.previous_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
Previous
</a>
{% else %}
<a class="pagination-previous is-disabled">Previous</a>
{% endif %}
{% if osint_pagination.has_next %}
<a
class="pagination-next"
href="{{ osint_pagination.next_url }}"
hx-get="{{ osint_pagination.next_url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
Next
</a>
{% else %}
<a class="pagination-next is-disabled">Next</a>
{% endif %}
<ul class="pagination-list">
{% for page in osint_pagination.pages %}
{% if page.ellipsis %}
<li><span class="pagination-ellipsis">&hellip;</span></li>
{% elif page.current %}
<li><a class="pagination-link is-current">{{ page.number }}</a></li>
{% else %}
<li>
<a
class="pagination-link"
href="{{ page.url }}"
hx-get="{{ page.url }}"
hx-target="#{{ osint_table_id }}"
hx-swap="outerHTML">
{{ page.number }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</nav>
{% endif %}
<p class="help has-text-grey">
{{ osint_result_count }} result{% if osint_result_count != 1 %}s{% endif %}
</p>
</div>

View File

@@ -0,0 +1,78 @@
<div class="box">
<form
class="osint-search-form"
method="get"
action="{{ osint_search_url }}"
hx-get="{{ osint_search_url }}"
hx-target="#osint-search-results"
hx-swap="innerHTML">
<div class="columns is-multiline">
<div class="column is-4">
<label class="label">Scope</label>
<div class="select is-fullwidth">
<select name="scope">
{% for option in scope_options %}
<option
value="{{ option.value }}"
{% if option.value == selected_scope %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-4">
<label class="label">Field</label>
<div class="select is-fullwidth">
<select name="field">
<option value="__all__" {% if selected_field == "__all__" %}selected{% endif %}>
All Fields
</option>
{% for option in field_options %}
<option
value="{{ option.value }}"
{% if option.value == selected_field %}selected{% endif %}>
{{ option.label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-4">
<label class="label">Rows Per Page</label>
<div class="select is-fullwidth">
<select name="per_page">
<option value="10" {% if selected_per_page == 10 %}selected{% endif %}>10</option>
<option value="20" {% if selected_per_page == 20 %}selected{% endif %}>20</option>
<option value="50" {% if selected_per_page == 50 %}selected{% endif %}>50</option>
<option value="100" {% if selected_per_page == 100 %}selected{% endif %}>100</option>
</select>
</div>
</div>
<div class="column is-9">
<label class="label">Search Query</label>
<input
class="input"
type="text"
name="q"
value="{{ search_query }}"
placeholder="Search text, values, or relation names...">
</div>
<div class="column is-3">
<label class="label">&nbsp;</label>
<div class="buttons">
<button class="button is-link is-light is-fullwidth" type="submit">
Search
</button>
<a class="button is-light is-fullwidth" href="{{ osint_search_url }}">
Reset
</a>
</div>
</div>
</div>
</form>
<div id="osint-search-results">
{% include "partials/results_table.html" %}
</div>
</div>

View File

@@ -0,0 +1 @@
{% include "partials/osint/list-table.html" %}

View File

@@ -0,0 +1,14 @@
{% if object.ok %}
<img src="data:image/png;base64, {{ object.image_b64 }}" alt="WhatsApp QR code" />
{% if object.warning %}
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
{% endif %}
{% else %}
<article class="notification is-warning is-light" style="margin-bottom: 0;">
<p><strong>WhatsApp QR Not Ready.</strong></p>
<p>{{ object.error|default:"No Neonize pairing QR is available yet." }}</p>
{% if object.warning %}
<p style="margin-top: 0.45rem;">{{ object.warning }}</p>
{% endif %}
</article>
{% endif %}

View File

@@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
import re
import time
from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlencode
from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import signing
from django.core.cache import cache
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
@@ -14,7 +17,21 @@ from django.utils import timezone as dj_timezone
from django.views import View
from core.clients import transport
from core.models import ChatSession, Message, Person, PersonIdentifier
from core.messaging import ai as ai_runner
from core.messaging.utils import messages_to_string
from core.models import (
AI,
ChatSession,
Message,
PatternMitigationPlan,
Person,
PersonIdentifier,
)
from core.views.workspace import _build_engage_payload, _parse_draft_options
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
def _default_service(service: str | None) -> str:
@@ -64,6 +81,185 @@ def _serialize_message(msg: Message) -> dict:
}
def _owner_name(user) -> str:
return (
user.first_name
or user.get_full_name().strip()
or user.username
or "Me"
)
def _compose_ws_token(user_id, service, identifier, person_id):
payload = {
"u": int(user_id),
"s": str(service or ""),
"i": str(identifier or ""),
"p": str(person_id) if person_id else "",
"exp": int(time.time()) + (60 * 60 * 12),
}
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
raw = "|".join(
[
str(kind or ""),
str(user_id),
str(service or ""),
str(identifier or ""),
str(person_id or ""),
str(last_ts or 0),
str(limit or 0),
]
)
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"compose:{kind}:{digest}"
def _plain_text(value):
cleaned = re.sub(r"\s+", " ", str(value or "").strip())
cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned)
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
return cleaned.strip()
def _engage_body_only(value):
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
if lines and lines[0].startswith("**"):
lines = lines[1:]
if lines and lines[0].lower() == "guidance:":
lines = lines[1:]
return _plain_text(" ".join(lines))
def _messages_for_ai(user, person_identifier, limit):
if person_identifier is None:
return []
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
rows = list(
Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person")
.order_by("-ts")[:limit]
)
rows.reverse()
return rows
def _fallback_drafts():
return [
{
"label": "Soft",
"text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.",
},
{
"label": "Neutral",
"text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.",
},
{
"label": "Firm",
"text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.",
},
]
def _build_draft_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Generate exactly three short reply drafts for a chat. "
"Return labels Soft, Neutral, Firm. "
"Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n"
"Each draft must be one to two sentences, plain text, no markdown."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _build_summary_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Create a concise conversation summary with three sections. "
"Use this exact structure:\n"
"Headlines:\n- ...\n"
"Patterns:\n- ...\n"
"Suggested Next Message:\n- ...\n"
"Keep each bullet practical and specific."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _build_engage_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Write one short de-escalating outreach in shared framing. "
"Use 'we/us/our' only. No names. One or two sentences."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _latest_plan_for_person(user, person):
if person is None:
return None
conversation = (
PatternMitigationPlan.objects.filter(
user=user,
conversation__participants=person,
)
.select_related("conversation")
.order_by("-updated_at")
.first()
)
return conversation
def _best_engage_source(plan):
if plan is None:
return (None, "")
correction = plan.corrections.order_by("-created_at").first()
if correction:
return (correction, "correction")
rule = plan.rules.order_by("-created_at").first()
if rule:
return (rule, "rule")
game = plan.games.order_by("-created_at").first()
if game:
return (game, "game")
return (None, "")
def _context_base(user, service, identifier, person):
person_identifier = None
if person is not None:
@@ -143,6 +339,13 @@ def _panel_context(
base["identifier"],
base["person"].id if base["person"] else None,
)
ws_token = _compose_ws_token(
user_id=request.user.id,
service=base["service"],
identifier=base["identifier"],
person_id=base["person"].id if base["person"] else None,
)
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
@@ -164,6 +367,11 @@ def _panel_context(
"render_mode": render_mode,
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_drafts_url": reverse("compose_drafts"),
"compose_summary_url": reverse("compose_summary"),
"compose_engage_preview_url": reverse("compose_engage_preview"),
"compose_engage_send_url": reverse("compose_engage_send"),
"compose_ws_url": ws_url,
"ai_workspace_url": (
f"{reverse('ai_workspace')}?person={base['person'].id}"
if base["person"]
@@ -305,6 +513,285 @@ class ComposeThread(LoginRequiredMixin, View):
return JsonResponse(payload)
class ComposeDrafts(LoginRequiredMixin, View):
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 = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
if not messages:
return JsonResponse(
{
"ok": True,
"cached": False,
"drafts": _fallback_drafts(),
}
)
last_ts = int(messages[-1].ts or 0)
cache_key = _compose_ai_cache_key(
"drafts",
request.user.id,
base["service"],
base["identifier"],
base["person"].id if base["person"] else "",
last_ts,
limit,
)
cached = cache.get(cache_key)
if cached:
return JsonResponse({"ok": True, "cached": True, "drafts": cached})
ai_obj = AI.objects.filter(user=request.user).first()
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
drafts = _fallback_drafts()
if ai_obj is not None:
try:
result = async_to_sync(ai_runner.run_prompt)(
_build_draft_prompt(
owner_name=_owner_name(request.user),
person_name=base["person"].name if base["person"] else "Other",
transcript=transcript,
),
ai_obj,
)
parsed = _parse_draft_options(result)
if parsed:
drafts = parsed
except Exception:
pass
cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "drafts": drafts})
class ComposeSummary(LoginRequiredMixin, View):
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 = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
if not messages:
return JsonResponse({"ok": True, "cached": False, "summary": ""})
last_ts = int(messages[-1].ts or 0)
cache_key = _compose_ai_cache_key(
"summary",
request.user.id,
base["service"],
base["identifier"],
base["person"].id if base["person"] else "",
last_ts,
limit,
)
cached = cache.get(cache_key)
if cached:
return JsonResponse({"ok": True, "cached": True, "summary": cached})
ai_obj = AI.objects.filter(user=request.user).first()
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
if ai_obj is None:
fallback = (
"Headlines:\n"
"- Conversation loaded.\n"
"Patterns:\n"
"- Not enough AI context configured yet.\n"
"Suggested Next Message:\n"
"- I want us to keep this clear and constructive."
)
cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "summary": fallback})
try:
summary = async_to_sync(ai_runner.run_prompt)(
_build_summary_prompt(
owner_name=_owner_name(request.user),
person_name=base["person"].name if base["person"] else "Other",
transcript=transcript,
),
ai_obj,
)
except Exception as exc:
return JsonResponse({"ok": False, "error": str(exc)})
summary = str(summary or "").strip()
cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "summary": summary})
class ComposeEngagePreview(LoginRequiredMixin, View):
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 = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
owner_name = _owner_name(request.user)
recipient_name = base["person"].name if base["person"] else "Other"
plan = _latest_plan_for_person(request.user, base["person"])
source_obj, source_kind = _best_engage_source(plan)
preview = ""
outbound = ""
artifact_label = "AI-generated"
if source_obj is not None:
payload = _build_engage_payload(
source_obj=source_obj,
source_kind=source_kind,
share_target="other",
framing="shared",
context_note="",
owner_name=owner_name,
recipient_name=recipient_name,
)
preview = str(payload.get("preview") or "").strip()
outbound = _engage_body_only(payload.get("outbound") or "")
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
else:
ai_obj = AI.objects.filter(user=request.user).first()
if ai_obj is not None:
try:
generated = async_to_sync(ai_runner.run_prompt)(
_build_engage_prompt(owner_name, recipient_name, transcript),
ai_obj,
)
outbound = _plain_text(generated)
except Exception:
outbound = ""
if not outbound:
outbound = (
"We should slow down, clarify what we mean, and respond with care."
)
preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}"
token = signing.dumps(
{
"u": request.user.id,
"s": base["service"],
"i": base["identifier"],
"p": str(base["person"].id) if base["person"] else "",
"outbound": outbound,
"exp": int(time.time()) + (60 * 10),
},
salt=COMPOSE_ENGAGE_TOKEN_SALT,
)
return JsonResponse(
{
"ok": True,
"preview": preview,
"outbound": outbound,
"token": token,
"artifact": artifact_label,
}
)
class ComposeEngageSend(LoginRequiredMixin, View):
def post(self, request):
service = _default_service(request.POST.get("service"))
identifier = str(request.POST.get("identifier") or "").strip()
person = None
person_id = request.POST.get("person")
if person_id:
person = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return JsonResponse(
{"ok": False, "error": "Enable both send safety switches first."}
)
token = str(request.POST.get("engage_token") or "").strip()
if not token:
return JsonResponse({"ok": False, "error": "Missing engage token."})
try:
payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT)
except Exception:
return JsonResponse({"ok": False, "error": "Invalid engage token."})
if int(payload.get("u") or 0) != int(request.user.id):
return JsonResponse({"ok": False, "error": "Token does not match user."})
if int(payload.get("exp") or 0) < int(time.time()):
return JsonResponse({"ok": False, "error": "Engage token expired."})
outbound = str(payload.get("outbound") or "").strip()
if not outbound:
return JsonResponse({"ok": False, "error": "Empty engage payload."})
base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=outbound,
attachments=[],
)
if not ts:
return JsonResponse({"ok": False, "error": "Send failed."})
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
text=outbound,
ts=ts_value,
delivered_ts=ts_value if str(ts).isdigit() else None,
custom_author="USER",
)
return JsonResponse({"ok": True, "message": "Shared engage sent."})
class ComposeSend(LoginRequiredMixin, View):
def post(self, request):
service = _default_service(request.POST.get("service"))
@@ -320,6 +807,18 @@ class ComposeSend(LoginRequiredMixin, View):
if not identifier and person is None:
return HttpResponseBadRequest("Missing contact identifier.")
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": "Enable both send safety switches before sending.",
"notice_level": "warning",
},
)
text = str(request.POST.get("text") or "").strip()
if not text:
return render(

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import GroupForm
from core.models import Group
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class GroupList(LoginRequiredMixin, ObjectList):
list_template = "partials/group-list.html"
class GroupList(LoginRequiredMixin, OSINTListBase):
osint_scope = "groups"
model = Group
page_title = "Groups"

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import ManipulationForm
from core.models import Manipulation
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class ManipulationList(LoginRequiredMixin, ObjectList):
list_template = "partials/manipulation-list.html"
class ManipulationList(LoginRequiredMixin, OSINTListBase):
osint_scope = "manipulations"
model = Manipulation
page_title = "Manipulations"

1013
core/views/osint.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonForm
from core.models import Person
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class PersonList(LoginRequiredMixin, ObjectList):
list_template = "partials/person-list.html"
class PersonList(LoginRequiredMixin, OSINTListBase):
osint_scope = "people"
model = Person
page_title = "People"
# page_subtitle = "Add times here in order to permit trading."
list_url_name = "people"
list_url_args = ["type"]

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonaForm
from core.models import Persona
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class PersonaList(LoginRequiredMixin, ObjectList):
list_template = "partials/persona-list.html"
class PersonaList(LoginRequiredMixin, OSINTListBase):
osint_scope = "personas"
model = Persona
page_title = "Personas"

View File

@@ -1,29 +1,94 @@
from django.conf import settings
from django.shortcuts import render
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
from core.views.manage.permissions import SuperUserRequiredMixin
class WhatsApp(Signal):
class WhatsApp(SuperUserRequiredMixin, View):
template_name = "pages/signal.html"
service = "whatsapp"
page_title = "WhatsApp"
accounts_url_name = "whatsapp_accounts"
def get(self, request):
return render(
request,
self.template_name,
{
"service": self.service,
"service_label": self.page_title,
"accounts_url_name": self.accounts_url_name,
},
)
class WhatsAppAccounts(SignalAccounts):
class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
list_template = "partials/signal-accounts.html"
service = "whatsapp"
context_object_name_singular = "WhatsApp Account"
context_object_name = "WhatsApp Accounts"
list_url_name = "whatsapp_accounts"
list_url_args = ["type"]
def _normalize_accounts(self, rows):
out = []
for item in rows or []:
if isinstance(item, dict):
value = (
item.get("number")
or item.get("id")
or item.get("jid")
or item.get("account")
)
if value:
out.append(str(value))
elif item:
out.append(str(item))
return out
def get_queryset(self, **kwargs):
self.extra_context = self._service_context(
service="whatsapp",
label="WhatsApp",
add_url_name="whatsapp_account_add",
show_contact_actions=False,
)
self.extra_context = {
"service": "whatsapp",
"service_label": "WhatsApp",
"account_add_url_name": "whatsapp_account_add",
"show_contact_actions": False,
"endpoint_base": str(
getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080")
).rstrip("/"),
"service_warning": transport.get_service_warning("whatsapp"),
}
return self._normalize_accounts(transport.list_accounts("whatsapp"))
class WhatsAppAccountAdd(SignalAccountAdd):
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
detail_template = "partials/whatsapp-account-add.html"
service = "whatsapp"
context_object_name_singular = "Add Account"
context_object_name = "Add Account"
detail_url_name = "whatsapp_account_add"
detail_url_args = ["type", "device"]
def post(self, request, *args, **kwargs):
self.request = request
return super().get(request, *args, **kwargs)
def get_object(self, **kwargs):
form_args = self.request.POST.dict()
device_name = form_args.get("device", "GIA Device")
try:
image_bytes = transport.get_link_qr(self.service, device_name)
return {
"ok": True,
"image_b64": transport.image_bytes_to_base64(image_bytes),
"warning": transport.get_service_warning(self.service),
}
except Exception as exc:
return {
"ok": False,
"error": str(exc),
"warning": transport.get_service_warning(self.service),
}

View File

@@ -155,6 +155,55 @@ INSIGHT_METRICS = {
"values often precede misunderstandings or withdrawal cycles."
),
},
"reciprocity_score": {
"title": "Reciprocity Component",
"group": "stability",
"history_field": "reciprocity_score",
"calculation": (
"100 * (1 - |inbound - outbound| / total_messages). Higher means "
"more balanced participation."
),
"psychology": (
"Lower reciprocity can reflect perceived asymmetry and rising pursuit/"
"withdraw cycles."
),
},
"continuity_score": {
"title": "Continuity Component",
"group": "stability",
"history_field": "continuity_score",
"calculation": (
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
"day-to-day continuity."
),
"psychology": (
"Drops can signal communication becoming episodic or reactive."
),
},
"response_score": {
"title": "Response Component",
"group": "stability",
"history_field": "response_score",
"calculation": (
"Average of inbound and outbound response-lag scores, each mapped from "
"median lag to a 0-100 curve."
),
"psychology": (
"Lower response score can indicate delayed repair loops during tension."
),
},
"volatility_score": {
"title": "Volatility Component",
"group": "stability",
"history_field": "volatility_score",
"calculation": (
"Derived from coefficient of variation of daily message counts and "
"inverted to a 0-100 stability signal."
),
"psychology": (
"High volatility can suggest inconsistent rhythm and reduced predictability."
),
},
"stability_confidence": {
"title": "Stability Confidence",
"group": "confidence",
@@ -219,6 +268,52 @@ INSIGHT_METRICS = {
"Estimates user follow-through and consistency toward the counterpart."
),
},
"inbound_response_score": {
"title": "Inbound Response Score",
"group": "commitment",
"history_field": "inbound_response_score",
"calculation": (
"Response-speed score built from median lag between user outbound and "
"counterpart inbound replies."
),
"psychology": (
"Lower values suggest delayed reciprocity from counterpart direction."
),
},
"outbound_response_score": {
"title": "Outbound Response Score",
"group": "commitment",
"history_field": "outbound_response_score",
"calculation": (
"Response-speed score built from median lag between counterpart inbound "
"and user outbound replies."
),
"psychology": "Lower values suggest slower follow-through from user direction.",
},
"balance_inbound_score": {
"title": "Inbound Balance Score",
"group": "commitment",
"history_field": "balance_inbound_score",
"calculation": (
"100 * min(1, inbound_messages / outbound_messages). Captures inbound "
"participation parity."
),
"psychology": (
"Lower values can indicate one-sided conversational load from user side."
),
},
"balance_outbound_score": {
"title": "Outbound Balance Score",
"group": "commitment",
"history_field": "balance_outbound_score",
"calculation": (
"100 * min(1, outbound_messages / inbound_messages). Captures outbound "
"participation parity."
),
"psychology": (
"Lower values can indicate one-sided conversational load from counterpart side."
),
},
"commitment_confidence": {
"title": "Commit Confidence",
"group": "confidence",
@@ -334,6 +429,78 @@ INSIGHT_GRAPH_SPECS = [
"y_min": 0,
"y_max": None,
},
{
"slug": "reciprocity_score",
"title": "Reciprocity Component",
"field": "reciprocity_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "continuity_score",
"title": "Continuity Component",
"field": "continuity_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "response_score",
"title": "Response Component",
"field": "response_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "volatility_score",
"title": "Volatility Component",
"field": "volatility_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "inbound_response_score",
"title": "Inbound Response Score",
"field": "inbound_response_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "outbound_response_score",
"title": "Outbound Response Score",
"field": "outbound_response_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "balance_inbound_score",
"title": "Inbound Balance Score",
"field": "balance_inbound_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "balance_outbound_score",
"title": "Outbound Balance Score",
"field": "balance_outbound_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "last_event",
"title": "Last Event Timestamp",
"field": "source_event_ts",
"group": "timeline",
"y_min": None,
"y_max": None,
},
]
@@ -487,7 +654,10 @@ def _to_float(value):
return float(value)
def _format_metric_value(conversation, metric_slug):
def _format_metric_value(conversation, metric_slug, latest_snapshot=None):
snapshot = latest_snapshot
if snapshot is None:
snapshot = conversation.metric_snapshots.first()
if metric_slug == "platform":
return conversation.get_platform_type_display() or "-"
if metric_slug == "thread":
@@ -498,6 +668,14 @@ def _format_metric_value(conversation, metric_slug):
return conversation.get_stability_state_display()
if metric_slug == "stability_score":
return conversation.stability_score
if metric_slug == "reciprocity_score":
return snapshot.reciprocity_score if snapshot else None
if metric_slug == "continuity_score":
return snapshot.continuity_score if snapshot else None
if metric_slug == "response_score":
return snapshot.response_score if snapshot else None
if metric_slug == "volatility_score":
return snapshot.volatility_score if snapshot else None
if metric_slug == "stability_confidence":
return conversation.stability_confidence
if metric_slug == "sample_messages":
@@ -510,6 +688,14 @@ def _format_metric_value(conversation, metric_slug):
return conversation.commitment_inbound_score
if metric_slug == "commitment_outbound":
return conversation.commitment_outbound_score
if metric_slug == "inbound_response_score":
return snapshot.inbound_response_score if snapshot else None
if metric_slug == "outbound_response_score":
return snapshot.outbound_response_score if snapshot else None
if metric_slug == "balance_inbound_score":
return snapshot.balance_inbound_score if snapshot else None
if metric_slug == "balance_outbound_score":
return snapshot.balance_outbound_score if snapshot else None
if metric_slug == "commitment_confidence":
return conversation.commitment_confidence
if metric_slug == "commitment_computed":
@@ -2713,7 +2899,8 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person)
value = _format_metric_value(conversation, metric)
latest_snapshot = conversation.metric_snapshots.first()
value = _format_metric_value(conversation, metric, latest_snapshot)
group = INSIGHT_GROUPS[spec["group"]]
points = []
if spec["history_field"]:
@@ -2773,6 +2960,7 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person)
latest_snapshot = conversation.metric_snapshots.first()
metrics = []
for slug, spec in INSIGHT_METRICS.items():
metrics.append(
@@ -2783,7 +2971,11 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
"calculation": spec["calculation"],
"psychology": spec["psychology"],
"value": _format_metric_value(conversation, slug),
"value": _format_metric_value(
conversation,
slug,
latest_snapshot,
),
}
)