Improve and condense related controls
This commit is contained in:
@@ -48,7 +48,13 @@ if DEBUG:
|
||||
SETTINGS_EXPORT = ["BILLING_ENABLED"]
|
||||
|
||||
SIGNAL_NUMBER = getenv("SIGNAL_NUMBER")
|
||||
SIGNAL_HTTP_URL = getenv("SIGNAL_HTTP_URL", "http://signal:8080")
|
||||
_container_runtime = getenv("container", "").strip().lower()
|
||||
_signal_default_url = (
|
||||
"http://127.0.0.1:8080"
|
||||
if _container_runtime == "podman"
|
||||
else "http://signal:8080"
|
||||
)
|
||||
SIGNAL_HTTP_URL = getenv("SIGNAL_HTTP_URL", _signal_default_url)
|
||||
|
||||
WHATSAPP_ENABLED = getenv("WHATSAPP_ENABLED", "false").lower() in trues
|
||||
WHATSAPP_HTTP_URL = getenv("WHATSAPP_HTTP_URL", "http://whatsapp:8080")
|
||||
|
||||
10
app/urls.py
10
app/urls.py
@@ -305,6 +305,16 @@ urlpatterns = [
|
||||
osint.OSINTSearch.as_view(),
|
||||
name="osint_search",
|
||||
),
|
||||
path(
|
||||
"osint/workspace/",
|
||||
osint.OSINTWorkspace.as_view(),
|
||||
name="osint_workspace",
|
||||
),
|
||||
path(
|
||||
"osint/workspace/widget/tabs/",
|
||||
osint.OSINTWorkspaceTabsWidget.as_view(),
|
||||
name="osint_workspace_tabs_widget",
|
||||
),
|
||||
path(
|
||||
"ai/<str:type>/create/",
|
||||
ais.AICreate.as_view(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
@@ -21,6 +22,11 @@ if _signal_http_url:
|
||||
parsed = urlparse(
|
||||
_signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}"
|
||||
)
|
||||
configured_host = (parsed.hostname or "").strip().lower()
|
||||
runtime = os.getenv("container", "").strip().lower()
|
||||
if configured_host == "signal" and runtime == "podman":
|
||||
SIGNAL_HOST = "127.0.0.1"
|
||||
else:
|
||||
SIGNAL_HOST = parsed.hostname or "signal"
|
||||
SIGNAL_PORT = parsed.port or 8080
|
||||
else:
|
||||
@@ -141,6 +147,18 @@ def _typing_started(typing_payload):
|
||||
return True
|
||||
|
||||
|
||||
def _identifier_candidates(*values):
|
||||
out = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
cleaned = str(value or "").strip()
|
||||
if not cleaned or cleaned in seen:
|
||||
continue
|
||||
seen.add(cleaned)
|
||||
out.append(cleaned)
|
||||
return out
|
||||
|
||||
|
||||
class NewSignalBot(SignalBot):
|
||||
def __init__(self, ur, service, config):
|
||||
self.ur = ur
|
||||
@@ -242,6 +260,8 @@ class HandleMessage(Command):
|
||||
source_uuid = c.message.source_uuid
|
||||
text = c.message.text
|
||||
ts = c.message.timestamp
|
||||
source_value = c.message.source
|
||||
envelope = raw.get("envelope", {})
|
||||
|
||||
# Message originating from us
|
||||
same_recipient = source_uuid == dest
|
||||
@@ -253,21 +273,33 @@ class HandleMessage(Command):
|
||||
reply_to_others = is_to_bot and not same_recipient # Reply
|
||||
is_outgoing_message = is_from_bot and not is_to_bot # Do not reply
|
||||
|
||||
# Determine the identifier to use
|
||||
identifier_uuid = dest if is_from_bot else source_uuid
|
||||
if not identifier_uuid:
|
||||
envelope_source_uuid = envelope.get("sourceUuid")
|
||||
envelope_source_number = envelope.get("sourceNumber")
|
||||
envelope_source = envelope.get("source")
|
||||
|
||||
primary_identifier = dest if is_from_bot else source_uuid
|
||||
identifier_candidates = _identifier_candidates(
|
||||
primary_identifier,
|
||||
source_uuid,
|
||||
source_number,
|
||||
source_value,
|
||||
envelope_source_uuid,
|
||||
envelope_source_number,
|
||||
envelope_source,
|
||||
dest,
|
||||
)
|
||||
if not identifier_candidates:
|
||||
log.warning("No Signal identifier available for message routing.")
|
||||
return
|
||||
|
||||
# Resolve person identifiers once for this event.
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
identifier=identifier_uuid,
|
||||
identifier__in=identifier_candidates,
|
||||
service=self.service,
|
||||
)
|
||||
)
|
||||
|
||||
envelope = raw.get("envelope", {})
|
||||
typing_payload = envelope.get("typingMessage")
|
||||
if isinstance(typing_payload, dict):
|
||||
for identifier in identifiers:
|
||||
@@ -300,7 +332,7 @@ class HandleMessage(Command):
|
||||
message_timestamps=read_timestamps,
|
||||
read_ts=read_ts,
|
||||
payload=receipt_payload,
|
||||
read_by=source_uuid,
|
||||
read_by=(source_uuid or source_number or ""),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -380,21 +412,44 @@ class HandleMessage(Command):
|
||||
attachments=xmpp_attachments,
|
||||
)
|
||||
|
||||
# TODO: Permission checks
|
||||
manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True))
|
||||
# Persist message history for every resolved identifier, even when no
|
||||
# manipulations are active, so manual chat windows stay complete.
|
||||
session_cache = {}
|
||||
stored_messages = set()
|
||||
for identifier in identifiers:
|
||||
session_key = (identifier.user.id, identifier.person.id)
|
||||
if session_key in session_cache:
|
||||
chat_session = session_cache[session_key]
|
||||
else:
|
||||
chat_session = await history.get_chat_session(identifier.user, identifier)
|
||||
session_cache[session_key] = chat_session
|
||||
sender_key = source_uuid or source_number or identifier_candidates[0]
|
||||
message_key = (chat_session.id, ts, sender_key)
|
||||
if message_key not in stored_messages:
|
||||
await history.store_message(
|
||||
session=chat_session,
|
||||
sender=sender_key,
|
||||
text=text,
|
||||
ts=ts,
|
||||
outgoing=is_from_bot,
|
||||
)
|
||||
stored_messages.add(message_key)
|
||||
|
||||
# TODO: Permission checks
|
||||
manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True))
|
||||
for manip in manips:
|
||||
try:
|
||||
person_identifier = await sync_to_async(PersonIdentifier.objects.get)(
|
||||
identifier=identifier_uuid,
|
||||
person_identifier = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
identifier__in=identifier_candidates,
|
||||
user=manip.user,
|
||||
service="signal",
|
||||
person__in=manip.group.people.all(),
|
||||
)
|
||||
except PersonIdentifier.DoesNotExist:
|
||||
).first()
|
||||
)()
|
||||
if person_identifier is None:
|
||||
log.warning(
|
||||
f"{manip.name}: Message from unknown identifier {identifier_uuid}."
|
||||
f"{manip.name}: Message from unknown identifier(s) "
|
||||
f"{', '.join(identifier_candidates)}."
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -408,19 +463,6 @@ class HandleMessage(Command):
|
||||
)
|
||||
session_cache[session_key] = chat_session
|
||||
|
||||
# Store each incoming/outgoing event once per session.
|
||||
message_key = (chat_session.id, ts, source_uuid)
|
||||
if message_key not in stored_messages:
|
||||
log.info(f"Processing history store message {text}")
|
||||
await history.store_message(
|
||||
session=chat_session,
|
||||
sender=source_uuid,
|
||||
text=text,
|
||||
ts=ts,
|
||||
outgoing=is_from_bot,
|
||||
)
|
||||
stored_messages.add(message_key)
|
||||
|
||||
# Get the total history
|
||||
chat_history = await history.get_chat_history(chat_session)
|
||||
|
||||
@@ -493,9 +535,18 @@ class HandleMessage(Command):
|
||||
else:
|
||||
log.error(f"Mode {manip.mode} is not implemented")
|
||||
|
||||
chat_lookup = {"account": account}
|
||||
if source_uuid:
|
||||
chat_lookup["source_uuid"] = source_uuid
|
||||
elif source_number:
|
||||
chat_lookup["source_number"] = source_number
|
||||
else:
|
||||
return
|
||||
|
||||
await sync_to_async(Chat.objects.update_or_create)(
|
||||
source_uuid=source_uuid,
|
||||
**chat_lookup,
|
||||
defaults={
|
||||
"source_uuid": source_uuid,
|
||||
"source_number": source_number,
|
||||
"source_name": source_name,
|
||||
"account": account,
|
||||
|
||||
@@ -124,6 +124,22 @@ def get_service_warning(service: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def request_pairing(service: str, device_name: str = ""):
|
||||
"""
|
||||
Mark a runtime pairing request so UR clients can refresh QR/pair state.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
if service_key not in {"whatsapp", "instagram"}:
|
||||
return
|
||||
device = str(device_name or "GIA Device").strip() or "GIA Device"
|
||||
update_runtime_state(
|
||||
service_key,
|
||||
pair_device=device,
|
||||
pair_requested_at=int(time.time()),
|
||||
warning="Waiting for runtime pairing QR.",
|
||||
)
|
||||
|
||||
|
||||
async def _gateway_json(method: str, url: str, payload=None):
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
|
||||
@@ -32,6 +32,7 @@ class WhatsAppClient(ClientBase):
|
||||
self._accounts = []
|
||||
self._chat_presence = None
|
||||
self._chat_presence_media = None
|
||||
self._last_pair_request = 0
|
||||
|
||||
self.enabled = bool(
|
||||
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
|
||||
@@ -120,8 +121,39 @@ class WhatsAppClient(ClientBase):
|
||||
|
||||
# Keep task alive so state/callbacks remain active.
|
||||
while not self._stopping:
|
||||
await self._sync_pair_request()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _sync_pair_request(self):
|
||||
state = transport.get_runtime_state(self.service)
|
||||
requested_at = int(state.get("pair_requested_at") or 0)
|
||||
if requested_at <= 0 or requested_at <= self._last_pair_request:
|
||||
return
|
||||
self._last_pair_request = requested_at
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
pair_qr="",
|
||||
warning="Waiting for WhatsApp QR from Neonize.",
|
||||
)
|
||||
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(self._client, "disconnect"):
|
||||
await self._maybe_await(self._client.disconnect())
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp disconnect before pairing failed: %s", exc)
|
||||
|
||||
try:
|
||||
await self._maybe_await(self._client.connect())
|
||||
except Exception as exc:
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
warning=f"WhatsApp pairing refresh failed: {exc}",
|
||||
)
|
||||
self.log.warning("whatsapp pairing refresh failed: %s", exc)
|
||||
|
||||
def _register_event(self, event_cls, callback):
|
||||
if event_cls is None:
|
||||
return
|
||||
|
||||
@@ -190,6 +190,26 @@
|
||||
.modal-background{
|
||||
background-color:rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
#modals-here .modal-background {
|
||||
background-color: rgba(0, 0, 0, 0.34) !important;
|
||||
}
|
||||
#modals-here .modal-content > .box {
|
||||
background-color: rgba(255, 255, 255, 0.97) !important;
|
||||
color: inherit;
|
||||
}
|
||||
#modals-here .modal-content .input,
|
||||
#modals-here .modal-content .textarea,
|
||||
#modals-here .modal-content .select select {
|
||||
background-color: rgba(255, 255, 255, 0.98) !important;
|
||||
}
|
||||
[data-theme="dark"] #modals-here .modal-content > .box {
|
||||
background-color: rgba(45, 45, 45, 0.97) !important;
|
||||
}
|
||||
[data-theme="dark"] #modals-here .modal-content .input,
|
||||
[data-theme="dark"] #modals-here .modal-content .textarea,
|
||||
[data-theme="dark"] #modals-here .modal-content .select select {
|
||||
background-color: rgba(33, 33, 33, 0.98) !important;
|
||||
}
|
||||
|
||||
.has-background-grey-lighter{
|
||||
background-color:rgba(219, 219, 219, 0.5) !important;
|
||||
@@ -340,25 +360,9 @@
|
||||
Queue
|
||||
</a>
|
||||
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">
|
||||
<a class="navbar-item" href="{% url 'osint_workspace' %}">
|
||||
OSINT
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a class="navbar-item" href="{% url 'people' type='page' %}">
|
||||
People
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'groups' type='page' %}">
|
||||
Groups
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'personas' type='page' %}">
|
||||
Personas
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'manipulations' type='page' %}">
|
||||
Manipulations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="navbar-item add-button">
|
||||
Install
|
||||
@@ -464,6 +468,54 @@
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
window.giaPrepareWidgetTarget = function () {
|
||||
const target = document.getElementById("widgets-here");
|
||||
if (target) {
|
||||
target.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
window.giaCanSpawnWidgets = function () {
|
||||
return !!(
|
||||
window.grid &&
|
||||
typeof window.grid.addWidget === "function" &&
|
||||
document.getElementById("grid-stack-main") &&
|
||||
document.getElementById("widgets-here")
|
||||
);
|
||||
};
|
||||
|
||||
window.giaEnableWidgetSpawnButtons = function (root) {
|
||||
const scope = root && root.querySelectorAll ? root : document;
|
||||
const canSpawn = window.giaCanSpawnWidgets();
|
||||
scope.querySelectorAll(".js-widget-spawn-trigger").forEach(function (button) {
|
||||
const widgetUrl = String(
|
||||
button.getAttribute("data-widget-url")
|
||||
|| button.getAttribute("hx-get")
|
||||
|| ""
|
||||
).trim();
|
||||
const visible = canSpawn && !!widgetUrl;
|
||||
button.classList.toggle("is-hidden", !visible);
|
||||
button.setAttribute("aria-hidden", visible ? "false" : "true");
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
const trigger = event.target.closest(".js-widget-spawn-trigger");
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
window.giaPrepareWidgetTarget();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
window.giaEnableWidgetSpawnButtons(document);
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterSwap", function (event) {
|
||||
const target = (event && event.target) || document;
|
||||
window.giaEnableWidgetSpawnButtons(target);
|
||||
});
|
||||
</script>
|
||||
{% block outer_content %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
|
||||
// re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid
|
||||
htmx.process(widgetelement);
|
||||
if (typeof window.giaEnableWidgetSpawnButtons === "function") {
|
||||
window.giaEnableWidgetSpawnButtons(widgetelement);
|
||||
}
|
||||
|
||||
// update the size of the widget according to its content
|
||||
var added_widget = htmx.find(grid_element, "#"+new_id);
|
||||
@@ -77,6 +80,9 @@
|
||||
// container.inner = "";
|
||||
// }
|
||||
grid.compact();
|
||||
if (typeof window.giaEnableWidgetSpawnButtons === "function") {
|
||||
window.giaEnableWidgetSpawnButtons(document);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div>
|
||||
|
||||
11
core/templates/pages/osint-workspace.html
Normal file
11
core/templates/pages/osint-workspace.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "index.html" %}
|
||||
|
||||
{% block load_widgets %}
|
||||
<div
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ tabs_widget_url }}"
|
||||
hx-target="#widgets-here"
|
||||
hx-trigger="load"
|
||||
hx-swap="afterend"
|
||||
style="display: none;"></div>
|
||||
{% endblock %}
|
||||
@@ -8,32 +8,49 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
{% if plan.source_ai_result_id %}
|
||||
<span class="tag is-light">Source Result {{ plan.source_ai_result_id }}</span>
|
||||
{% endif %}
|
||||
<form
|
||||
class="box mitigation-artifact-card mitigation-editable-shell"
|
||||
style="padding: 0.45rem; margin: 0; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url 'ai_workspace_mitigation_meta_save' type='widget' person_id=person.id plan_id=plan.id %}"
|
||||
hx-target="#mitigation-shell-{{ person.id }}"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||
<div class="mitigation-artifact-headline">
|
||||
<p class="mitigation-artifact-title">Plan Details</p>
|
||||
<div class="mitigation-artifact-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-link is-light is-rounded mitigation-edit-btn"
|
||||
data-edit-state="view"
|
||||
title="Edit plan details"
|
||||
onclick="giaMitigationToggleEdit(this); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mitigation-artifact-meta">Created {{ plan.created_at }} · Updated {{ plan.updated_at }}</p>
|
||||
<p class="mitigation-artifact-preview">
|
||||
{{ plan.creation_mode|title }} / {{ plan.status|title }}
|
||||
{% if plan.source_ai_result_id %}
|
||||
· Source Result {{ plan.source_ai_result_id }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="mitigation-edit-fields">
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<div class="control">
|
||||
<input class="input is-small" type="text" name="title" value="{{ plan.title }}" placeholder="Plan title">
|
||||
<input class="input is-small" type="text" name="title" value="{{ plan.title }}" placeholder="Plan title" data-editable="1" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<div class="control">
|
||||
<textarea class="textarea is-small" rows="2" name="objective" placeholder="Plan objective">{{ plan.objective }}</textarea>
|
||||
<textarea class="textarea is-small" rows="2" name="objective" placeholder="Plan objective" data-editable="1" readonly>{{ plan.objective }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped is-grouped-right" style="margin: 0; gap: 0.3rem;">
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select name="creation_mode">
|
||||
<select name="creation_mode" data-editable-toggle="1" disabled>
|
||||
{% for value, label in plan_creation_mode_choices %}
|
||||
<option value="{{ value }}" {% if plan.creation_mode == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -42,15 +59,13 @@
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select name="status">
|
||||
<select name="status" data-editable-toggle="1" disabled>
|
||||
{% for value, label in plan_status_choices %}
|
||||
<option value="{{ value }}" {% if plan.status == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-small is-light">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -263,30 +278,61 @@
|
||||
|
||||
{% if corrections %}
|
||||
{% for correction in corrections %}
|
||||
<article class="box" style="padding: 0.55rem; margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Correction</span>
|
||||
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Created {{ correction.created_at }}</span>
|
||||
<article class="box mitigation-artifact-card mitigation-editable-shell" 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='correction' artifact_id=correction.id %}"
|
||||
hx-target="#mitigation-shell-{{ person.id }}"
|
||||
hx-swap="outerHTML">
|
||||
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
|
||||
<div class="column is-12" style="padding: 0.3rem;">
|
||||
<input class="input is-small" type="text" name="title" value="{{ correction.title }}">
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'corrections' }}">
|
||||
<div class="mitigation-artifact-headline">
|
||||
<p class="mitigation-artifact-title">{{ correction.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 correction.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 correction"
|
||||
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 correction"
|
||||
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='correction' artifact_id=correction.id %}"
|
||||
hx-vals='{"active_tab":"corrections"}'
|
||||
hx-confirm="Delete this correction?"
|
||||
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 class="column is-12" style="padding: 0.3rem;">
|
||||
</div>
|
||||
<p class="mitigation-artifact-meta">Created {{ correction.created_at }}</p>
|
||||
<p class="mitigation-artifact-preview">{{ correction.clarification }}</p>
|
||||
<div class="mitigation-edit-fields">
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<input class="input is-small" type="text" name="title" value="{{ correction.title }}" data-editable="1" readonly>
|
||||
</div>
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Message Context</label>
|
||||
<textarea class="textarea is-small" rows="2" name="source_phrase">{{ correction.source_phrase }}</textarea>
|
||||
<textarea class="textarea is-small" rows="2" name="source_phrase" data-editable="1" readonly>{{ correction.source_phrase }}</textarea>
|
||||
</div>
|
||||
<div class="column is-12" style="padding: 0.3rem;">
|
||||
<div class="field" style="margin-bottom: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Insight</label>
|
||||
<textarea class="textarea is-small" rows="2" name="body">{{ correction.clarification }}</textarea>
|
||||
<textarea class="textarea is-small" rows="2" name="body" data-editable="1" readonly>{{ correction.clarification }}</textarea>
|
||||
</div>
|
||||
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
|
||||
<div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Perspective</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="perspective">
|
||||
<select name="perspective" data-editable-toggle="1" disabled>
|
||||
{% for value, label in correction.PERSPECTIVE_CHOICES %}
|
||||
<option value="{{ value }}" {% if correction.perspective == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -296,7 +342,7 @@
|
||||
<div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Share Target</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="share_target">
|
||||
<select name="share_target" data-editable-toggle="1" disabled>
|
||||
{% for value, label in correction.SHARE_TARGET_CHOICES %}
|
||||
<option value="{{ value }}" {% if correction.share_target == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -306,7 +352,7 @@
|
||||
<div class="column is-12-mobile is-4-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.2rem;">Language Style</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="language_style">
|
||||
<select name="language_style" data-editable-toggle="1" disabled>
|
||||
{% for value, label in correction.LANGUAGE_STYLE_CHOICES %}
|
||||
<option value="{{ value }}" {% if correction.language_style == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -314,22 +360,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox is-size-7" style="margin-bottom: 0.35rem;">
|
||||
<input type="checkbox" name="enabled" value="1" {% if correction.enabled %}checked{% endif %}>
|
||||
Enabled
|
||||
</label>
|
||||
<input type="hidden" name="active_tab" value="{{ active_tab|default:'corrections' }}">
|
||||
<div class="buttons are-small" style="margin: 0;">
|
||||
<button class="button is-small is-link is-light">Save Correction</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small 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='correction' artifact_id=correction.id %}"
|
||||
hx-vals='{"active_tab":"corrections"}'
|
||||
hx-confirm="Delete this correction?"
|
||||
hx-target="#mitigation-shell-{{ person.id }}"
|
||||
hx-swap="outerHTML">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
@@ -511,18 +541,33 @@
|
||||
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if auto_settings.enabled %}checked{% endif %}> Enable auto checks for this Conversation</label>
|
||||
<p class="is-size-7 has-text-grey" style="margin-top: 0.2rem; margin-bottom: 0;">
|
||||
Master gate. When off, automatic checks return early and no auto plan, correction, or notification actions run for this conversation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="auto_pattern_recognition" value="1" {% if auto_settings.auto_pattern_recognition %}checked{% endif %}> Detect pattern signals from Message rows</label>
|
||||
<p class="is-size-7 has-text-grey" style="margin-top: 0.2rem; margin-bottom: 0;">
|
||||
Controls background trigger behavior only. If disabled, auto-triggered scans are skipped; manual "Run Check Now" can still run.
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="auto_create_mitigation" value="1" {% if auto_settings.auto_create_mitigation %}checked{% endif %}> Create a Plan when the Conversation has none</label>
|
||||
<p class="is-size-7 has-text-grey" style="margin-top: 0.2rem; margin-bottom: 0;">
|
||||
On AI pane load, if no plan exists and automation is enabled, a baseline plan is auto-created from recent messages.
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="auto_create_corrections" value="1" {% if auto_settings.auto_create_corrections %}checked{% endif %}> Create Correction rows linked to the Plan</label>
|
||||
<p class="is-size-7 has-text-grey" style="margin-top: 0.2rem; margin-bottom: 0;">
|
||||
Writes up to 8 detected correction candidates per run, deduplicated by title + clarification, and links them to this plan.
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="auto_notify_enabled" value="1" {% if auto_settings.auto_notify_enabled %}checked{% endif %}> Notify when auto writes new Correction rows</label>
|
||||
<p class="is-size-7 has-text-grey" style="margin-top: 0.2rem; margin-bottom: 0;">
|
||||
Sends a notification when violations are found (with count + top preview), using NTFY overrides if provided, otherwise default notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||
<label class="label is-small" style="margin-bottom: 0.25rem;">Message rows per check</label>
|
||||
|
||||
@@ -76,6 +76,21 @@
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>Manual Text Mode</span>
|
||||
</a>
|
||||
{% if compose_widget_url %}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-light is-small js-widget-spawn-trigger is-hidden"
|
||||
data-widget-url="{{ compose_widget_url }}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ compose_widget_url }}"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend"
|
||||
title="Open Manual Text widget here"
|
||||
aria-label="Open Manual Text widget here">
|
||||
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||
<span>Widget</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -113,37 +128,74 @@
|
||||
<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="tags" style="margin-bottom: 0.25rem;">
|
||||
<span class="tag is-info is-light is-small">Mitigation</span>
|
||||
<span class="tag is-info is-light is-small">Control</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 ai-top-tab-mitigation">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'plan_board', false); return false;">Plan</a>
|
||||
<a
|
||||
title="Control plan board"
|
||||
aria-label="Control plan board"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'plan_board', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-diagram-project"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li id="ai-tab-{{ person.id }}-corrections" class="ai-top-tab-mitigation">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'corrections', false); return false;">Corrections</a>
|
||||
<a
|
||||
title="Corrections"
|
||||
aria-label="Corrections"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'corrections', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-screwdriver-wrench"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li id="ai-tab-{{ person.id }}-engage" class="ai-top-tab-mitigation">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'engage', false); return false;">Engage</a>
|
||||
<a
|
||||
title="Engage"
|
||||
aria-label="Engage"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'engage', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<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" class="ai-top-tab-mitigation">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'auto', false); return false;">Auto</a>
|
||||
<a
|
||||
title="Fundamentals"
|
||||
aria-label="Fundamentals"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'fundamentals', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-book-open"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li id="ai-tab-{{ person.id }}-ask_ai" class="ai-top-tab-mitigation">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'ask_ai', false); return false;">Ask AI</a>
|
||||
<a
|
||||
title="Ask AI"
|
||||
aria-label="Ask AI"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'ask_ai', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-robot"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li id="ai-tab-{{ person.id }}-summarise" class="ai-top-tab-output ai-top-tab-output-start">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'summarise', false); return false;">Summary</a>
|
||||
<a
|
||||
title="Summary"
|
||||
aria-label="Summary"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'summarise', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-file-lines"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li id="ai-tab-{{ person.id }}-draft_reply" class="ai-top-tab-output">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'draft_reply', false); return false;">Draft</a>
|
||||
<a
|
||||
title="Draft"
|
||||
aria-label="Draft"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'draft_reply', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-pen-to-square"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
<li id="ai-tab-{{ person.id }}-extract_patterns" class="ai-top-tab-output">
|
||||
<a onclick="giaWorkspaceOpenTab('{{ person.id }}', 'extract_patterns', false); return false;">Patterns</a>
|
||||
<a
|
||||
title="Patterns"
|
||||
aria-label="Patterns"
|
||||
onclick="giaWorkspaceOpenTab('{{ person.id }}', 'extract_patterns', false); return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -208,14 +260,18 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.55rem;
|
||||
min-height: 2.2rem;
|
||||
min-width: 2.2rem;
|
||||
padding: 0 0.55rem;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.ai-person-widget .ai-top-tabs li a {
|
||||
min-height: 2.8rem;
|
||||
min-height: 2.1rem;
|
||||
min-width: 2.1rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -356,7 +412,7 @@
|
||||
}
|
||||
|
||||
const OPERATION_TABS = ["summarise", "draft_reply", "extract_patterns"];
|
||||
const MITIGATION_TABS = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"];
|
||||
const MITIGATION_TABS = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
|
||||
const ALL_TOP_TABS = MITIGATION_TABS.concat(OPERATION_TABS);
|
||||
|
||||
function isMitigationTab(tabKey) {
|
||||
@@ -483,6 +539,7 @@
|
||||
if (cacheAllowed && !forceRefresh && entry) {
|
||||
pane.innerHTML = entry.html;
|
||||
pane.dataset.loaded = "1";
|
||||
executeInlineScripts(pane);
|
||||
pane.classList.remove("ai-animate-in");
|
||||
void pane.offsetWidth;
|
||||
pane.classList.add("ai-animate-in");
|
||||
@@ -649,7 +706,7 @@
|
||||
|
||||
if (typeof window.giaMitigationShowTab !== "function") {
|
||||
window.giaMitigationShowTab = function(pid, tabName) {
|
||||
const names = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"];
|
||||
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
|
||||
names.forEach(function(name) {
|
||||
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
|
||||
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
|
||||
|
||||
@@ -34,6 +34,21 @@
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>AI Workspace</span>
|
||||
</a>
|
||||
{% if ai_workspace_widget_url %}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-light is-rounded is-small js-widget-spawn-trigger is-hidden"
|
||||
data-widget-url="{{ ai_workspace_widget_url }}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-get="{{ ai_workspace_widget_url }}"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend"
|
||||
title="Open AI Person widget here"
|
||||
aria-label="Open AI Person widget here">
|
||||
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
|
||||
<span>Widget</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
@@ -109,6 +124,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="{{ panel_id }}-lightbox" class="compose-lightbox is-hidden" aria-hidden="true">
|
||||
<button
|
||||
type="button"
|
||||
id="{{ panel_id }}-lightbox-prev"
|
||||
class="compose-lightbox-nav compose-lightbox-prev"
|
||||
aria-label="Previous image">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chevron-left"></i></span>
|
||||
</button>
|
||||
<button type="button" class="compose-lightbox-close" aria-label="Close image preview">
|
||||
<span class="icon is-small"><i class="fa-solid fa-xmark"></i></span>
|
||||
</button>
|
||||
<figure class="compose-lightbox-frame">
|
||||
<img
|
||||
id="{{ panel_id }}-lightbox-image"
|
||||
class="compose-lightbox-image"
|
||||
src=""
|
||||
alt="Conversation attachment preview">
|
||||
</figure>
|
||||
<button
|
||||
type="button"
|
||||
id="{{ panel_id }}-lightbox-next"
|
||||
class="compose-lightbox-nav compose-lightbox-next"
|
||||
aria-label="Next image">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chevron-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{{ panel_id }}-glance"
|
||||
@@ -340,6 +381,86 @@
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
object-fit: cover;
|
||||
background: #f8f8f8;
|
||||
cursor: zoom-in;
|
||||
transition: filter 120ms ease;
|
||||
}
|
||||
#{{ panel_id }} .compose-image:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
#{{ panel_id }}-lightbox.compose-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 160;
|
||||
background: rgba(10, 12, 16, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
#{{ panel_id }}-lightbox.compose-lightbox.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-frame {
|
||||
margin: 0;
|
||||
max-width: min(96vw, 70rem);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-image {
|
||||
display: block;
|
||||
max-width: min(96vw, 70rem);
|
||||
max-height: 88vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-close {
|
||||
position: absolute;
|
||||
top: 0.8rem;
|
||||
right: 0.8rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
background: rgba(10, 12, 16, 0.62);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
background: rgba(10, 12, 16, 0.62);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-prev {
|
||||
left: 0.8rem;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-next {
|
||||
right: 0.8rem;
|
||||
}
|
||||
#{{ panel_id }}-lightbox .compose-lightbox-nav:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
#{{ panel_id }} .compose-body {
|
||||
margin: 0 0 0.2rem 0;
|
||||
@@ -687,6 +808,12 @@
|
||||
#{{ panel_id }} .compose-qi-chip .k {
|
||||
color: #657283;
|
||||
font-size: 0.67rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-chip .v {
|
||||
font-size: 0.78rem;
|
||||
@@ -735,6 +862,25 @@
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-doc-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
min-width: 0.5rem;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #7a94b4;
|
||||
cursor: help;
|
||||
opacity: 0.85;
|
||||
transform: translateY(0.02rem);
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-doc-dot:hover,
|
||||
#{{ panel_id }} .compose-qi-doc-dot:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 1px solid rgba(52, 101, 164, 0.45);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
#{{ panel_id }} .compose-qi-row-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -870,7 +1016,14 @@
|
||||
const glanceNode = document.getElementById(panelId + "-glance");
|
||||
const popover = document.getElementById(panelId + "-popover");
|
||||
const popoverBackdrop = document.getElementById(panelId + "-popover-backdrop");
|
||||
const lightbox = document.getElementById(panelId + "-lightbox");
|
||||
const lightboxImage = document.getElementById(panelId + "-lightbox-image");
|
||||
const lightboxPrev = document.getElementById(panelId + "-lightbox-prev");
|
||||
const lightboxNext = document.getElementById(panelId + "-lightbox-next");
|
||||
const csrfToken = "{{ csrf_token }}";
|
||||
if (lightbox && lightbox.parentElement !== document.body) {
|
||||
document.body.appendChild(lightbox);
|
||||
}
|
||||
|
||||
window.giaComposePanels = window.giaComposePanels || {};
|
||||
const previousState = window.giaComposePanels[panelId];
|
||||
@@ -889,13 +1042,19 @@
|
||||
if (previousState && previousState.resizeHandler) {
|
||||
window.removeEventListener("resize", previousState.resizeHandler);
|
||||
}
|
||||
if (previousState && previousState.lightboxKeyHandler) {
|
||||
document.removeEventListener("keydown", previousState.lightboxKeyHandler);
|
||||
}
|
||||
const panelState = {
|
||||
timer: null,
|
||||
polling: false,
|
||||
socket: null,
|
||||
websocketReady: false,
|
||||
activePanel: null,
|
||||
engageToken: ""
|
||||
engageToken: "",
|
||||
lightboxKeyHandler: null,
|
||||
lightboxImages: [],
|
||||
lightboxIndex: -1,
|
||||
};
|
||||
window.giaComposePanels[panelId] = panelState;
|
||||
const triggerButtons = Array.from(panel.querySelectorAll(".js-ai-trigger"));
|
||||
@@ -942,6 +1101,84 @@
|
||||
}
|
||||
return String(Math.floor(ts / 60000));
|
||||
};
|
||||
const collectLightboxImages = function () {
|
||||
return Array.from(thread.querySelectorAll(".compose-image"));
|
||||
};
|
||||
const syncLightboxNav = function () {
|
||||
const total = panelState.lightboxImages.length;
|
||||
const index = panelState.lightboxIndex;
|
||||
if (lightboxPrev) {
|
||||
lightboxPrev.disabled = total < 2 || index <= 0;
|
||||
}
|
||||
if (lightboxNext) {
|
||||
lightboxNext.disabled = total < 2 || index >= (total - 1);
|
||||
}
|
||||
};
|
||||
const openLightboxAt = function (index) {
|
||||
if (!lightbox || !lightboxImage) {
|
||||
return;
|
||||
}
|
||||
const images = collectLightboxImages();
|
||||
if (!images.length) {
|
||||
return;
|
||||
}
|
||||
const safeIndex = Math.max(0, Math.min(Number(index) || 0, images.length - 1));
|
||||
const imageNode = images[safeIndex];
|
||||
const source = String(imageNode.currentSrc || imageNode.src || "").trim();
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
panelState.lightboxImages = images;
|
||||
panelState.lightboxIndex = safeIndex;
|
||||
lightboxImage.src = source;
|
||||
lightbox.classList.remove("is-hidden");
|
||||
lightbox.setAttribute("aria-hidden", "false");
|
||||
syncLightboxNav();
|
||||
};
|
||||
const openLightboxFromElement = function (imageNode) {
|
||||
const images = collectLightboxImages();
|
||||
if (!images.length) {
|
||||
return;
|
||||
}
|
||||
const idx = images.indexOf(imageNode);
|
||||
openLightboxAt(idx >= 0 ? idx : 0);
|
||||
};
|
||||
const stepLightbox = function (delta) {
|
||||
if (!lightbox || lightbox.classList.contains("is-hidden")) {
|
||||
return;
|
||||
}
|
||||
if (!panelState.lightboxImages.length) {
|
||||
panelState.lightboxImages = collectLightboxImages();
|
||||
}
|
||||
if (!panelState.lightboxImages.length) {
|
||||
return;
|
||||
}
|
||||
openLightboxAt(panelState.lightboxIndex + delta);
|
||||
};
|
||||
const closeLightbox = function () {
|
||||
if (!lightbox) {
|
||||
return;
|
||||
}
|
||||
lightbox.classList.add("is-hidden");
|
||||
lightbox.setAttribute("aria-hidden", "true");
|
||||
if (lightboxImage) {
|
||||
lightboxImage.removeAttribute("src");
|
||||
}
|
||||
panelState.lightboxImages = [];
|
||||
panelState.lightboxIndex = -1;
|
||||
syncLightboxNav();
|
||||
};
|
||||
const openLightbox = function (srcValue) {
|
||||
const source = String(srcValue || "").trim();
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const images = collectLightboxImages();
|
||||
const idx = images.findIndex(function (img) {
|
||||
return String(img.currentSrc || img.src || "").trim() === source;
|
||||
});
|
||||
openLightboxAt(idx >= 0 ? idx : 0);
|
||||
};
|
||||
|
||||
let lastTs = toInt(thread.dataset.lastTs);
|
||||
let glanceState = {
|
||||
@@ -1017,6 +1254,27 @@
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
scope.querySelectorAll(".compose-image").forEach(function (img) {
|
||||
if (img.dataset.lightboxBound === "1") {
|
||||
return;
|
||||
}
|
||||
img.dataset.lightboxBound = "1";
|
||||
img.setAttribute("role", "button");
|
||||
img.setAttribute("tabindex", "0");
|
||||
img.setAttribute("aria-label", "Open image preview");
|
||||
img.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openLightboxFromElement(img);
|
||||
});
|
||||
img.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
openLightboxFromElement(img);
|
||||
});
|
||||
});
|
||||
scope.querySelectorAll(".compose-bubble").forEach(function (bubble) {
|
||||
const fallback = bubble.querySelector(".compose-image-fallback");
|
||||
const refresh = function () {
|
||||
@@ -1642,6 +1900,36 @@
|
||||
const docs = Array.isArray(payload.docs) ? payload.docs : [];
|
||||
container.innerHTML = "";
|
||||
|
||||
const docsTooltip = function (title, calculation, psychology) {
|
||||
const parts = [];
|
||||
if (calculation) {
|
||||
parts.push("How it is calculated: " + String(calculation || ""));
|
||||
}
|
||||
if (psychology) {
|
||||
parts.push("Psychological interpretation: " + String(psychology || ""));
|
||||
}
|
||||
if (!parts.length) {
|
||||
return "";
|
||||
}
|
||||
return String(title || "Metric") + " | " + parts.join(" | ");
|
||||
};
|
||||
|
||||
const appendDocDot = function (target, tooltipText, titleText) {
|
||||
if (!target || !tooltipText) {
|
||||
return;
|
||||
}
|
||||
const dot = document.createElement("button");
|
||||
dot.type = "button";
|
||||
dot.className = "compose-qi-doc-dot";
|
||||
dot.title = String(tooltipText || "");
|
||||
dot.setAttribute("aria-label", "Explain " + String(titleText || "metric"));
|
||||
dot.addEventListener("click", function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
target.appendChild(dot);
|
||||
};
|
||||
|
||||
const stateFaceMeta = function (stateText) {
|
||||
const state = String(stateText || "").toLowerCase();
|
||||
if (state.includes("balanced")) {
|
||||
@@ -1697,28 +1985,67 @@
|
||||
const head = document.createElement("div");
|
||||
head.className = "compose-qi-head";
|
||||
[
|
||||
{ key: "Platform", value: summary.platform || "-" },
|
||||
{
|
||||
key: "Platform",
|
||||
value: summary.platform || "-",
|
||||
docs: summary.platform_docs || {},
|
||||
},
|
||||
{
|
||||
key: "Participant State",
|
||||
value: summary.state || "-",
|
||||
icon: stateFace.icon,
|
||||
className: stateFace.className,
|
||||
docs: summary.state_docs || {},
|
||||
},
|
||||
{
|
||||
key: "Data Points",
|
||||
value: String(summary.snapshot_count || 0),
|
||||
docs: summary.snapshot_docs || {},
|
||||
},
|
||||
{
|
||||
key: "Thread",
|
||||
value: summary.thread || "-",
|
||||
docs: summary.thread_docs || {},
|
||||
},
|
||||
{ key: "Data Points", value: String(summary.snapshot_count || 0) },
|
||||
{ key: "Thread", value: summary.thread || "-" },
|
||||
].forEach(function (pair) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "compose-qi-chip";
|
||||
let valueHtml = String(pair.value || "-");
|
||||
if (pair.icon) {
|
||||
valueHtml = (
|
||||
'<span class="' + String(pair.className || "") + '">'
|
||||
+ '<span class="icon is-small"><i class="' + String(pair.icon) + '"></i></span>'
|
||||
+ "</span>"
|
||||
+ "<span>" + valueHtml + "</span>"
|
||||
|
||||
const key = document.createElement("p");
|
||||
key.className = "k";
|
||||
const keyText = document.createElement("span");
|
||||
keyText.textContent = String(pair.key || "");
|
||||
key.appendChild(keyText);
|
||||
const pairDocs = pair.docs || {};
|
||||
appendDocDot(
|
||||
key,
|
||||
docsTooltip(
|
||||
pair.key,
|
||||
pairDocs.calculation,
|
||||
pairDocs.psychology
|
||||
),
|
||||
pair.key
|
||||
);
|
||||
chip.appendChild(key);
|
||||
|
||||
const value = document.createElement("p");
|
||||
value.className = "v";
|
||||
if (pair.icon) {
|
||||
const iconWrap = document.createElement("span");
|
||||
iconWrap.className = String(pair.className || "");
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "icon is-small";
|
||||
const glyph = document.createElement("i");
|
||||
glyph.className = String(pair.icon || "");
|
||||
icon.appendChild(glyph);
|
||||
iconWrap.appendChild(icon);
|
||||
value.appendChild(iconWrap);
|
||||
}
|
||||
chip.innerHTML = '<p class="k">' + pair.key + "</p>" + '<p class="v">' + valueHtml + "</p>";
|
||||
const valueText = document.createElement("span");
|
||||
valueText.textContent = String(pair.value || "-");
|
||||
value.appendChild(valueText);
|
||||
chip.appendChild(value);
|
||||
|
||||
head.appendChild(chip);
|
||||
});
|
||||
container.appendChild(head);
|
||||
@@ -1734,24 +2061,72 @@
|
||||
rows.forEach(function (row) {
|
||||
const node = document.createElement("article");
|
||||
node.className = "compose-qi-row";
|
||||
node.innerHTML = (
|
||||
'<div class="compose-qi-row-head">'
|
||||
+ '<p class="compose-qi-row-label"><span class="icon is-small"><i class="'
|
||||
+ String(row.icon || "fa-solid fa-square") + '"></i></span><span>'
|
||||
+ String(row.label || "") + "</span></p>"
|
||||
+ '<p class="compose-qi-row-meta"><span>' + String(row.point_count || 0)
|
||||
+ ' points</span><span class="' + String((row.trend || {}).class_name || "")
|
||||
+ '"><span class="icon is-small"><i class="' + String((row.trend || {}).icon || "")
|
||||
+ '"></i></span> ' + String(row.delta_label || "n/a")
|
||||
+ "</span></p></div>"
|
||||
+ '<div class="compose-qi-row-body">'
|
||||
+ '<p class="compose-qi-value">' + String(row.display_value || "-") + "</p>"
|
||||
+ '<p class="' + String(((row.emotion || {}).class_name) || "")
|
||||
+ '" style="margin:0; font-size:0.72rem;">'
|
||||
+ '<span class="icon is-small"><i class="' + String(((row.emotion || {}).icon) || "")
|
||||
+ '"></i></span> ' + String(((row.emotion || {}).label) || "Unknown")
|
||||
+ "</p></div>"
|
||||
|
||||
const rowHead = document.createElement("div");
|
||||
rowHead.className = "compose-qi-row-head";
|
||||
|
||||
const rowLabel = document.createElement("p");
|
||||
rowLabel.className = "compose-qi-row-label";
|
||||
const rowIcon = document.createElement("span");
|
||||
rowIcon.className = "icon is-small";
|
||||
const rowIconGlyph = document.createElement("i");
|
||||
rowIconGlyph.className = String(row.icon || "fa-solid fa-square");
|
||||
rowIcon.appendChild(rowIconGlyph);
|
||||
rowLabel.appendChild(rowIcon);
|
||||
const rowLabelText = document.createElement("span");
|
||||
rowLabelText.textContent = String(row.label || "");
|
||||
rowLabel.appendChild(rowLabelText);
|
||||
appendDocDot(
|
||||
rowLabel,
|
||||
docsTooltip(row.label, row.calculation, row.psychology),
|
||||
row.label
|
||||
);
|
||||
rowHead.appendChild(rowLabel);
|
||||
|
||||
const rowMeta = document.createElement("p");
|
||||
rowMeta.className = "compose-qi-row-meta";
|
||||
const points = document.createElement("span");
|
||||
points.textContent = String(row.point_count || 0) + " points";
|
||||
rowMeta.appendChild(points);
|
||||
const trend = row.trend || {};
|
||||
const trendNode = document.createElement("span");
|
||||
trendNode.className = String(trend.class_name || "");
|
||||
const trendIcon = document.createElement("span");
|
||||
trendIcon.className = "icon is-small";
|
||||
const trendGlyph = document.createElement("i");
|
||||
trendGlyph.className = String(trend.icon || "");
|
||||
trendIcon.appendChild(trendGlyph);
|
||||
trendNode.appendChild(trendIcon);
|
||||
const trendText = document.createTextNode(" " + String(row.delta_label || "n/a"));
|
||||
trendNode.appendChild(trendText);
|
||||
rowMeta.appendChild(trendNode);
|
||||
rowHead.appendChild(rowMeta);
|
||||
node.appendChild(rowHead);
|
||||
|
||||
const rowBody = document.createElement("div");
|
||||
rowBody.className = "compose-qi-row-body";
|
||||
const rowValue = document.createElement("p");
|
||||
rowValue.className = "compose-qi-value";
|
||||
rowValue.textContent = String(row.display_value || "-");
|
||||
rowBody.appendChild(rowValue);
|
||||
|
||||
const emotion = row.emotion || {};
|
||||
const emotionNode = document.createElement("p");
|
||||
emotionNode.className = String(emotion.class_name || "");
|
||||
emotionNode.style.margin = "0";
|
||||
emotionNode.style.fontSize = "0.72rem";
|
||||
const emotionIconWrap = document.createElement("span");
|
||||
emotionIconWrap.className = "icon is-small";
|
||||
const emotionGlyph = document.createElement("i");
|
||||
emotionGlyph.className = String(emotion.icon || "");
|
||||
emotionIconWrap.appendChild(emotionGlyph);
|
||||
emotionNode.appendChild(emotionIconWrap);
|
||||
emotionNode.appendChild(
|
||||
document.createTextNode(" " + String(emotion.label || "Unknown"))
|
||||
);
|
||||
rowBody.appendChild(emotionNode);
|
||||
node.appendChild(rowBody);
|
||||
|
||||
list.appendChild(node);
|
||||
});
|
||||
container.appendChild(list);
|
||||
@@ -1991,6 +2366,53 @@
|
||||
hideAllCards();
|
||||
});
|
||||
}
|
||||
if (lightbox) {
|
||||
if (lightboxPrev) {
|
||||
lightboxPrev.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stepLightbox(-1);
|
||||
});
|
||||
}
|
||||
if (lightboxNext) {
|
||||
lightboxNext.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stepLightbox(1);
|
||||
});
|
||||
}
|
||||
const closeButton = lightbox.querySelector(".compose-lightbox-close");
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
closeLightbox();
|
||||
});
|
||||
}
|
||||
lightbox.addEventListener("click", function (event) {
|
||||
if (event.target === lightbox) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
panelState.lightboxKeyHandler = function (event) {
|
||||
if (lightbox.classList.contains("is-hidden")) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
closeLightbox();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
stepLightbox(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
stepLightbox(1);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", panelState.lightboxKeyHandler);
|
||||
}
|
||||
panelState.resizeHandler = function () {
|
||||
if (!popover || popover.classList.contains("is-hidden")) {
|
||||
return;
|
||||
@@ -2056,6 +2478,9 @@
|
||||
document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
|
||||
document.body.removeEventListener("composeSendResult", panelState.sendResultHandler);
|
||||
document.removeEventListener("mousedown", panelState.docClickHandler);
|
||||
if (panelState.lightboxKeyHandler) {
|
||||
document.removeEventListener("keydown", panelState.lightboxKeyHandler);
|
||||
}
|
||||
if (panelState.socket) {
|
||||
try {
|
||||
panelState.socket.close();
|
||||
@@ -2063,6 +2488,9 @@
|
||||
// Ignore.
|
||||
}
|
||||
}
|
||||
if (lightbox && lightbox.parentElement === document.body) {
|
||||
lightbox.remove();
|
||||
}
|
||||
delete window.giaComposePanels[panelId];
|
||||
return;
|
||||
}
|
||||
|
||||
71
core/templates/partials/osint-workspace-tabs-widget.html
Normal file
71
core/templates/partials/osint-workspace-tabs-widget.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="osint-workspace-tabs">
|
||||
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">
|
||||
OSINT Workspace
|
||||
</p>
|
||||
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.5rem;">
|
||||
One-line setup capsule. Each tab opens a fresh setup widget.
|
||||
</p>
|
||||
|
||||
<div class="osint-capsule-row" role="tablist" aria-label="OSINT setup tabs">
|
||||
{% for tab in tabs %}
|
||||
<button
|
||||
type="button"
|
||||
class="button osint-capsule-tab"
|
||||
hx-get="{{ tab.widget_url }}"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="afterend"
|
||||
onclick="document.getElementById('widgets-here').style.display='block';"
|
||||
title="Open {{ tab.label }} setup widget">
|
||||
<span class="icon is-small"><i class="{{ tab.icon }}"></i></span>
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.osint-capsule-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0.3rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 254, 0.95), rgba(255, 255, 255, 0.96));
|
||||
}
|
||||
|
||||
.osint-capsule-tab {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
white-space: nowrap;
|
||||
min-height: 2rem;
|
||||
padding-left: 0.7rem;
|
||||
padding-right: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.osint-capsule-tab:hover,
|
||||
.osint-capsule-tab:focus-visible {
|
||||
border-color: rgba(50, 115, 220, 0.5);
|
||||
color: #2f67b2;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.osint-capsule-row {
|
||||
gap: 0.28rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.osint-capsule-tab {
|
||||
min-height: 1.82rem;
|
||||
padding-left: 0.55rem;
|
||||
padding-right: 0.55rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -70,6 +70,7 @@
|
||||
onclick="return false;">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>{{ column.label }}</span>
|
||||
<span class="is-size-7 has-text-grey ml-2">({{ column.field_name }})</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -284,11 +285,18 @@
|
||||
<script>
|
||||
(function () {
|
||||
const tableId = "{{ osint_table_id|escapejs }}";
|
||||
const scopeKey = "{{ osint_scope|default:'global'|escapejs }}";
|
||||
const columnSignature = "{{ osint_columns|length }}:{% for column in osint_columns %}{{ column.key|escapejs }}|{% endfor %}";
|
||||
const shell = document.getElementById(tableId);
|
||||
if (!shell) {
|
||||
return;
|
||||
}
|
||||
const storageKey = "gia_osint_hidden_cols_v1:" + tableId;
|
||||
const storageKey = [
|
||||
"gia_osint_hidden_cols_v2",
|
||||
tableId,
|
||||
scopeKey,
|
||||
columnSignature,
|
||||
].join(":");
|
||||
let hidden = [];
|
||||
try {
|
||||
hidden = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<div class="whatsapp-account-add-fragment">
|
||||
{% if object.ok %}
|
||||
<img src="data:image/png;base64, {{ object.image_b64 }}" alt="WhatsApp QR code" />
|
||||
{% if object.warning %}
|
||||
@@ -10,5 +11,21 @@
|
||||
{% if object.warning %}
|
||||
<p style="margin-top: 0.45rem;">{{ object.warning }}</p>
|
||||
{% endif %}
|
||||
{% if object.pending %}
|
||||
<p class="is-size-7" style="margin-top: 0.5rem;">
|
||||
Waiting for Neonize QR event. This panel will refresh automatically.
|
||||
</p>
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{{ detail_url }}"
|
||||
hx-target="closest .whatsapp-account-add-fragment"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="load delay:1800ms">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="device" value="{{ object.device|default:'GIA Device' }}" />
|
||||
<input type="hidden" name="refresh" value="1" />
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.models import (
|
||||
AI,
|
||||
ChatSession,
|
||||
Message,
|
||||
MessageEvent,
|
||||
PatternMitigationPlan,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
@@ -46,6 +47,12 @@ IMAGE_EXTENSIONS = (
|
||||
".avif",
|
||||
".svg",
|
||||
)
|
||||
EMPTY_TEXT_VALUES = {
|
||||
"",
|
||||
"[No Body]",
|
||||
"[no body]",
|
||||
"(no text)",
|
||||
}
|
||||
|
||||
|
||||
def _uniq_ordered(values):
|
||||
@@ -144,6 +151,122 @@ def _image_urls_from_text(text_value: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _looks_like_image_name(name_value: str) -> bool:
|
||||
value = str(name_value or "").strip().lower()
|
||||
return bool(value) and value.endswith(IMAGE_EXTENSIONS)
|
||||
|
||||
|
||||
def _extract_attachment_image_urls(blob) -> list[str]:
|
||||
urls = []
|
||||
if isinstance(blob, str):
|
||||
normalized = _clean_url(blob)
|
||||
if normalized and _looks_like_image_url(normalized):
|
||||
urls.append(normalized)
|
||||
return urls
|
||||
|
||||
if isinstance(blob, dict):
|
||||
content_type = str(
|
||||
blob.get("content_type")
|
||||
or blob.get("contentType")
|
||||
or blob.get("mime_type")
|
||||
or blob.get("mimetype")
|
||||
or ""
|
||||
).strip().lower()
|
||||
filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
|
||||
image_hint = content_type.startswith("image/") or _looks_like_image_name(filename)
|
||||
|
||||
for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
|
||||
normalized = _clean_url(blob.get(key))
|
||||
if not normalized:
|
||||
continue
|
||||
if image_hint or _looks_like_image_url(normalized):
|
||||
urls.append(normalized)
|
||||
|
||||
nested = blob.get("attachments")
|
||||
if isinstance(nested, list):
|
||||
for row in nested:
|
||||
urls.extend(_extract_attachment_image_urls(row))
|
||||
return urls
|
||||
|
||||
if isinstance(blob, list):
|
||||
for row in blob:
|
||||
urls.extend(_extract_attachment_image_urls(row))
|
||||
return urls
|
||||
|
||||
|
||||
def _attachment_image_urls_by_message(messages):
|
||||
rows = list(messages or [])
|
||||
if not rows:
|
||||
return {}
|
||||
|
||||
by_message = {}
|
||||
unresolved = []
|
||||
|
||||
for msg in rows:
|
||||
text_value = str(msg.text or "").strip()
|
||||
if text_value and text_value not in EMPTY_TEXT_VALUES:
|
||||
continue
|
||||
unresolved.append(msg)
|
||||
|
||||
if not unresolved:
|
||||
return by_message
|
||||
|
||||
legacy_ids = [str(msg.id) for msg in unresolved]
|
||||
linked_events = MessageEvent.objects.filter(
|
||||
user=rows[0].user,
|
||||
raw_payload_ref__legacy_message_id__in=legacy_ids,
|
||||
).order_by("ts")
|
||||
|
||||
for event in linked_events:
|
||||
legacy_id = str((event.raw_payload_ref or {}).get("legacy_message_id") or "").strip()
|
||||
if not legacy_id:
|
||||
continue
|
||||
urls = _uniq_ordered(
|
||||
_extract_attachment_image_urls(event.attachments)
|
||||
+ _extract_attachment_image_urls(event.raw_payload_ref or {})
|
||||
)
|
||||
if urls:
|
||||
by_message.setdefault(legacy_id, urls)
|
||||
|
||||
missing = [msg for msg in unresolved if str(msg.id) not in by_message]
|
||||
if not missing:
|
||||
return by_message
|
||||
|
||||
min_ts = min(int(msg.ts or 0) for msg in missing) - 3000
|
||||
max_ts = max(int(msg.ts or 0) for msg in missing) + 3000
|
||||
fallback_events = (
|
||||
MessageEvent.objects.filter(
|
||||
user=rows[0].user,
|
||||
source_system="xmpp",
|
||||
ts__gte=min_ts,
|
||||
ts__lte=max_ts,
|
||||
)
|
||||
.exclude(attachments=[])
|
||||
.order_by("ts")
|
||||
)
|
||||
fallback_list = list(fallback_events)
|
||||
for msg in missing:
|
||||
if str(msg.id) in by_message:
|
||||
continue
|
||||
msg_ts = int(msg.ts or 0)
|
||||
candidates = [
|
||||
event
|
||||
for event in fallback_list
|
||||
if abs(int(event.ts or 0) - msg_ts) <= 3000
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
event = candidates[0]
|
||||
urls = _uniq_ordered(
|
||||
_extract_attachment_image_urls(event.attachments)
|
||||
+ _extract_attachment_image_urls(event.raw_payload_ref or {})
|
||||
)
|
||||
if urls:
|
||||
by_message[str(msg.id)] = urls
|
||||
|
||||
return by_message
|
||||
|
||||
|
||||
def _serialize_message(msg: Message) -> dict:
|
||||
text_value = str(msg.text or "")
|
||||
image_urls = _image_urls_from_text(text_value)
|
||||
@@ -448,6 +571,21 @@ def _serialize_messages_with_artifacts(
|
||||
):
|
||||
rows = list(messages or [])
|
||||
serialized = [_serialize_message(msg) for msg in rows]
|
||||
attachment_images = _attachment_image_urls_by_message(rows)
|
||||
for idx, msg in enumerate(rows):
|
||||
item = serialized[idx]
|
||||
if item.get("image_urls"):
|
||||
continue
|
||||
recovered = _uniq_ordered(attachment_images.get(str(msg.id)) or [])
|
||||
if not recovered:
|
||||
continue
|
||||
item["image_urls"] = recovered
|
||||
item["image_url"] = recovered[0]
|
||||
text_value = str(msg.text or "").strip()
|
||||
if text_value in EMPTY_TEXT_VALUES:
|
||||
item["hide_text"] = True
|
||||
item["display_text"] = ""
|
||||
|
||||
for item in serialized:
|
||||
item["gap_fragments"] = []
|
||||
item["metric_fragments"] = []
|
||||
@@ -815,6 +953,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "stability_score",
|
||||
"label": "Stability Score",
|
||||
"doc_slug": "stability_score",
|
||||
"field": "stability_score",
|
||||
"source": "conversation",
|
||||
"kind": "score",
|
||||
@@ -824,6 +963,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "stability_confidence",
|
||||
"label": "Stability Confidence",
|
||||
"doc_slug": "stability_confidence",
|
||||
"field": "stability_confidence",
|
||||
"source": "conversation",
|
||||
"kind": "confidence",
|
||||
@@ -833,6 +973,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "sample_messages",
|
||||
"label": "Sample Messages",
|
||||
"doc_slug": "sample_messages",
|
||||
"field": "stability_sample_messages",
|
||||
"source": "conversation",
|
||||
"kind": "count",
|
||||
@@ -842,6 +983,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "sample_days",
|
||||
"label": "Sample Days",
|
||||
"doc_slug": "sample_days",
|
||||
"field": "stability_sample_days",
|
||||
"source": "conversation",
|
||||
"kind": "count",
|
||||
@@ -851,6 +993,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "commitment_inbound",
|
||||
"label": "Commit In",
|
||||
"doc_slug": "commitment_inbound",
|
||||
"field": "commitment_inbound_score",
|
||||
"source": "conversation",
|
||||
"kind": "score",
|
||||
@@ -860,6 +1003,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "commitment_outbound",
|
||||
"label": "Commit Out",
|
||||
"doc_slug": "commitment_outbound",
|
||||
"field": "commitment_outbound_score",
|
||||
"source": "conversation",
|
||||
"kind": "score",
|
||||
@@ -869,6 +1013,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "commitment_confidence",
|
||||
"label": "Commit Confidence",
|
||||
"doc_slug": "commitment_confidence",
|
||||
"field": "commitment_confidence",
|
||||
"source": "conversation",
|
||||
"kind": "confidence",
|
||||
@@ -878,6 +1023,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "reciprocity",
|
||||
"label": "Reciprocity",
|
||||
"doc_slug": "reciprocity_score",
|
||||
"field": "reciprocity_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
@@ -887,6 +1033,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "continuity",
|
||||
"label": "Continuity",
|
||||
"doc_slug": "continuity_score",
|
||||
"field": "continuity_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
@@ -896,6 +1043,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "response",
|
||||
"label": "Response",
|
||||
"doc_slug": "response_score",
|
||||
"field": "response_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
@@ -905,6 +1053,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "volatility",
|
||||
"label": "Volatility",
|
||||
"doc_slug": "volatility_score",
|
||||
"field": "volatility_score",
|
||||
"source": "snapshot",
|
||||
"kind": "score",
|
||||
@@ -914,6 +1063,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "inbound_messages",
|
||||
"label": "Inbound Messages",
|
||||
"doc_slug": "inbound_messages",
|
||||
"field": "inbound_messages",
|
||||
"source": "snapshot",
|
||||
"kind": "count",
|
||||
@@ -923,6 +1073,7 @@ def _quick_insights_rows(conversation):
|
||||
{
|
||||
"key": "outbound_messages",
|
||||
"label": "Outbound Messages",
|
||||
"doc_slug": "outbound_messages",
|
||||
"field": "outbound_messages",
|
||||
"source": "snapshot",
|
||||
"kind": "count",
|
||||
@@ -933,6 +1084,7 @@ def _quick_insights_rows(conversation):
|
||||
rows = []
|
||||
for spec in metric_specs:
|
||||
field_name = spec["field"]
|
||||
metric_copy = _metric_copy(spec.get("doc_slug") or spec["key"], spec["label"])
|
||||
if spec["source"] == "conversation":
|
||||
current = getattr(conversation, field_name, None)
|
||||
previous_value = getattr(previous, field_name, None) if previous else None
|
||||
@@ -964,6 +1116,8 @@ def _quick_insights_rows(conversation):
|
||||
"point_count": point_count,
|
||||
"trend": trend,
|
||||
"emotion": emotion,
|
||||
"calculation": metric_copy.get("calculation") or "",
|
||||
"psychology": metric_copy.get("psychology") or "",
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -1092,6 +1246,14 @@ def _engage_source_from_ref(plan, source_ref):
|
||||
def _context_base(user, service, identifier, person):
|
||||
person_identifier = None
|
||||
if person is not None:
|
||||
if identifier:
|
||||
person_identifier = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
).first()
|
||||
if person_identifier is None:
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
@@ -1190,7 +1352,9 @@ def _panel_context(
|
||||
)
|
||||
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
|
||||
|
||||
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
||||
unique_raw = (
|
||||
f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}"
|
||||
)
|
||||
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
||||
typing_state = get_person_typing_state(
|
||||
user_id=request.user.id,
|
||||
@@ -1228,6 +1392,14 @@ def _panel_context(
|
||||
if base["person"]
|
||||
else reverse("ai_workspace")
|
||||
),
|
||||
"ai_workspace_widget_url": (
|
||||
(
|
||||
f"{reverse('ai_workspace_person', kwargs={'type': 'widget', 'person_id': base['person'].id})}"
|
||||
f"?{urlencode({'limit': limit})}"
|
||||
)
|
||||
if base["person"]
|
||||
else ""
|
||||
),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
@@ -1637,6 +1809,18 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
"last_ai_run": "",
|
||||
"workspace_created": "",
|
||||
"snapshot_count": 0,
|
||||
"platform_docs": _metric_copy("platform", "Platform"),
|
||||
"state_docs": _metric_copy("stability_state", "Participant State"),
|
||||
"thread_docs": _metric_copy("thread", "Thread"),
|
||||
"snapshot_docs": {
|
||||
"calculation": (
|
||||
"Count of stored workspace metric snapshots for this person."
|
||||
),
|
||||
"psychology": (
|
||||
"More points improve trend reliability; sparse points are "
|
||||
"best treated as directional signals."
|
||||
),
|
||||
},
|
||||
},
|
||||
"rows": [],
|
||||
"docs": [
|
||||
@@ -1673,6 +1857,18 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
conversation.created_at
|
||||
).strftime("%Y-%m-%d %H:%M"),
|
||||
"snapshot_count": payload["snapshot_count"],
|
||||
"platform_docs": _metric_copy("platform", "Platform"),
|
||||
"state_docs": _metric_copy("stability_state", "Participant State"),
|
||||
"thread_docs": _metric_copy("thread", "Thread"),
|
||||
"snapshot_docs": {
|
||||
"calculation": (
|
||||
"Count of stored workspace metric snapshots for this person."
|
||||
),
|
||||
"psychology": (
|
||||
"More points improve trend reliability; sparse points are "
|
||||
"best treated as directional signals."
|
||||
),
|
||||
},
|
||||
},
|
||||
"rows": payload["rows"],
|
||||
"docs": [
|
||||
|
||||
@@ -45,6 +45,14 @@ def _preferred_related_text_field(model: type[models.Model]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _column_field_name(column: "OsintColumn") -> str:
|
||||
if column.search_lookup:
|
||||
return str(column.search_lookup).split("__", 1)[0]
|
||||
if column.sort_field:
|
||||
return str(column.sort_field).split("__", 1)[0]
|
||||
return str(column.key)
|
||||
|
||||
|
||||
def _url_with_query(base_url: str, query: dict[str, Any]) -> str:
|
||||
params = {}
|
||||
for key, value in query.items():
|
||||
@@ -465,6 +473,8 @@ class OSINTListBase(ObjectList):
|
||||
if not column.sort_field:
|
||||
columns.append(
|
||||
{
|
||||
"key": column.key,
|
||||
"field_name": _column_field_name(column),
|
||||
"label": column.label,
|
||||
"sortable": False,
|
||||
"kind": column.kind,
|
||||
@@ -482,6 +492,8 @@ class OSINTListBase(ObjectList):
|
||||
)
|
||||
columns.append(
|
||||
{
|
||||
"key": column.key,
|
||||
"field_name": _column_field_name(column),
|
||||
"label": column.label,
|
||||
"sortable": True,
|
||||
"kind": column.kind,
|
||||
@@ -815,6 +827,8 @@ class OSINTSearch(LoginRequiredMixin, View):
|
||||
if not column.sort_field:
|
||||
columns.append(
|
||||
{
|
||||
"key": column.key,
|
||||
"field_name": _column_field_name(column),
|
||||
"label": column.label,
|
||||
"sortable": False,
|
||||
"kind": column.kind,
|
||||
@@ -832,6 +846,8 @@ class OSINTSearch(LoginRequiredMixin, View):
|
||||
)
|
||||
columns.append(
|
||||
{
|
||||
"key": column.key,
|
||||
"field_name": _column_field_name(column),
|
||||
"label": column.label,
|
||||
"sortable": True,
|
||||
"kind": column.kind,
|
||||
@@ -1019,3 +1035,51 @@ class OSINTSearch(LoginRequiredMixin, View):
|
||||
return render(request, self.widget_template, widget_context)
|
||||
|
||||
return render(request, self.page_template, context)
|
||||
|
||||
|
||||
class OSINTWorkspace(LoginRequiredMixin, View):
|
||||
template_name = "pages/osint-workspace.html"
|
||||
|
||||
def get(self, request):
|
||||
context = {
|
||||
"tabs_widget_url": reverse("osint_workspace_tabs_widget"),
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
|
||||
class OSINTWorkspaceTabsWidget(LoginRequiredMixin, View):
|
||||
def get(self, request):
|
||||
tabs = [
|
||||
{
|
||||
"key": "people",
|
||||
"label": "People",
|
||||
"icon": "fa-solid fa-user-group",
|
||||
"widget_url": reverse("people", kwargs={"type": "widget"}),
|
||||
},
|
||||
{
|
||||
"key": "groups",
|
||||
"label": "Groups",
|
||||
"icon": "fa-solid fa-users",
|
||||
"widget_url": reverse("groups", kwargs={"type": "widget"}),
|
||||
},
|
||||
{
|
||||
"key": "personas",
|
||||
"label": "Personas",
|
||||
"icon": "fa-solid fa-masks-theater",
|
||||
"widget_url": reverse("personas", kwargs={"type": "widget"}),
|
||||
},
|
||||
{
|
||||
"key": "manipulations",
|
||||
"label": "Manipulations",
|
||||
"icon": "fa-solid fa-sliders",
|
||||
"widget_url": reverse("manipulations", kwargs={"type": "widget"}),
|
||||
},
|
||||
]
|
||||
context = {
|
||||
"title": "OSINT Workspace",
|
||||
"unique": "osint-workspace-tabs",
|
||||
"window_content": "partials/osint-workspace-tabs-widget.html",
|
||||
"widget_options": 'gs-w="12" gs-h="4" gs-x="0" gs-y="0" gs-min-w="6"',
|
||||
"tabs": tabs,
|
||||
}
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
@@ -68,13 +69,40 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
detail_url_name = "whatsapp_account_add"
|
||||
detail_url_args = ["type", "device"]
|
||||
|
||||
def _device_name(self) -> str:
|
||||
form_args = self.request.POST.dict()
|
||||
return form_args.get("device", "GIA Device")
|
||||
|
||||
def _refresh_only(self) -> bool:
|
||||
form_args = self.request.POST.dict()
|
||||
return str(form_args.get("refresh") or "") == "1"
|
||||
|
||||
def _detail_context(self, kwargs, obj):
|
||||
detail_url_args = {
|
||||
arg: kwargs[arg]
|
||||
for arg in self.detail_url_args
|
||||
if arg in kwargs
|
||||
}
|
||||
return {
|
||||
"object": obj,
|
||||
"detail_url": reverse(self.detail_url_name, kwargs=detail_url_args),
|
||||
}
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
if self._refresh_only() and request.htmx:
|
||||
obj = self.get_object(**kwargs)
|
||||
return render(
|
||||
request,
|
||||
self.detail_template,
|
||||
self._detail_context(kwargs, obj),
|
||||
)
|
||||
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")
|
||||
device_name = self._device_name()
|
||||
if not self._refresh_only():
|
||||
transport.request_pairing(self.service, device_name)
|
||||
try:
|
||||
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||
return {
|
||||
@@ -83,8 +111,11 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
"warning": transport.get_service_warning(self.service),
|
||||
}
|
||||
except Exception as exc:
|
||||
error_text = str(exc)
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"pending": "pairing qr" in error_text.lower(),
|
||||
"device": device_name,
|
||||
"error": error_text,
|
||||
"warning": transport.get_service_warning(self.service),
|
||||
}
|
||||
|
||||
@@ -640,6 +640,31 @@ def _compose_page_url_for_person(user, person):
|
||||
return f"{reverse('compose_page')}?{query}"
|
||||
|
||||
|
||||
def _compose_widget_url_for_person(user, person, limit=40):
|
||||
preferred_service = _preferred_service_for_person(user, person)
|
||||
identifier_row = _resolve_person_identifier(
|
||||
user=user,
|
||||
person=person,
|
||||
preferred_service=preferred_service,
|
||||
)
|
||||
if identifier_row is None:
|
||||
return ""
|
||||
try:
|
||||
safe_limit = int(limit or 40)
|
||||
except (TypeError, ValueError):
|
||||
safe_limit = 40
|
||||
safe_limit = max(10, min(safe_limit, 200))
|
||||
query = urlencode(
|
||||
{
|
||||
"service": identifier_row.service,
|
||||
"identifier": identifier_row.identifier,
|
||||
"person": str(person.id),
|
||||
"limit": safe_limit,
|
||||
}
|
||||
)
|
||||
return f"{reverse('compose_widget')}?{query}"
|
||||
|
||||
|
||||
def _participant_feedback_display(conversation, person):
|
||||
payload = conversation.participant_feedback or {}
|
||||
if not isinstance(payload, dict):
|
||||
@@ -3438,6 +3463,11 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
||||
],
|
||||
"send_state": _get_send_state(request.user, person),
|
||||
"compose_page_url": _compose_page_url_for_person(request.user, person),
|
||||
"compose_widget_url": _compose_widget_url_for_person(
|
||||
request.user,
|
||||
person,
|
||||
limit=limit,
|
||||
),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
}
|
||||
return render(request, "mixins/wm/widget.html", context)
|
||||
|
||||
Reference in New Issue
Block a user