Implement AI workspace and mitigation workflow
This commit is contained in:
@@ -52,4 +52,4 @@ SIGNAL_NUMBER = getenv("SIGNAL_NUMBER")
|
|||||||
XMPP_ADDRESS = getenv("XMPP_ADDRESS")
|
XMPP_ADDRESS = getenv("XMPP_ADDRESS")
|
||||||
XMPP_JID = getenv("XMPP_JID")
|
XMPP_JID = getenv("XMPP_JID")
|
||||||
XMPP_PORT = getenv("XMPP_PORT")
|
XMPP_PORT = getenv("XMPP_PORT")
|
||||||
XMPP_SECRET = getenv("XMPP_SECRET")
|
XMPP_SECRET = getenv("XMPP_SECRET")
|
||||||
|
|||||||
160
app/urls.py
160
app/urls.py
@@ -21,7 +21,21 @@ from django.urls import include, path
|
|||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from two_factor.urls import urlpatterns as tf_urls
|
from two_factor.urls import urlpatterns as tf_urls
|
||||||
|
|
||||||
from core.views import base, notifications, signal, people, ais, groups, personas, manipulations, identifiers, sessions, messages, queues
|
from core.views import (
|
||||||
|
ais,
|
||||||
|
base,
|
||||||
|
groups,
|
||||||
|
identifiers,
|
||||||
|
manipulations,
|
||||||
|
messages,
|
||||||
|
notifications,
|
||||||
|
people,
|
||||||
|
personas,
|
||||||
|
queues,
|
||||||
|
sessions,
|
||||||
|
signal,
|
||||||
|
workspace,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("__debug__/", include("debug_toolbar.urls")),
|
path("__debug__/", include("debug_toolbar.urls")),
|
||||||
@@ -68,6 +82,86 @@ urlpatterns = [
|
|||||||
name="signal_account_add",
|
name="signal_account_add",
|
||||||
),
|
),
|
||||||
# AIs
|
# AIs
|
||||||
|
path(
|
||||||
|
"ai/workspace/",
|
||||||
|
workspace.AIWorkspace.as_view(),
|
||||||
|
name="ai_workspace",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/contacts/",
|
||||||
|
workspace.AIWorkspaceContactsWidget.as_view(),
|
||||||
|
name="ai_workspace_contacts",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/",
|
||||||
|
workspace.AIWorkspacePersonWidget.as_view(),
|
||||||
|
name="ai_workspace_person",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/run/<str:operation>/",
|
||||||
|
workspace.AIWorkspaceRunOperation.as_view(),
|
||||||
|
name="ai_workspace_run",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/send/",
|
||||||
|
workspace.AIWorkspaceSendDraft.as_view(),
|
||||||
|
name="ai_workspace_send",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/queue/",
|
||||||
|
workspace.AIWorkspaceQueueDraft.as_view(),
|
||||||
|
name="ai_workspace_queue",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/create/",
|
||||||
|
workspace.AIWorkspaceCreateMitigation.as_view(),
|
||||||
|
name="ai_workspace_mitigation_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/chat/",
|
||||||
|
workspace.AIWorkspaceMitigationChat.as_view(),
|
||||||
|
name="ai_workspace_mitigation_chat",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/export/",
|
||||||
|
workspace.AIWorkspaceExportArtifact.as_view(),
|
||||||
|
name="ai_workspace_mitigation_export",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/create/<str:kind>/",
|
||||||
|
workspace.AIWorkspaceCreateArtifact.as_view(),
|
||||||
|
name="ai_workspace_mitigation_artifact_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/save/",
|
||||||
|
workspace.AIWorkspaceUpdateArtifact.as_view(),
|
||||||
|
name="ai_workspace_mitigation_artifact_save",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/<uuid:artifact_id>/delete/",
|
||||||
|
workspace.AIWorkspaceDeleteArtifact.as_view(),
|
||||||
|
name="ai_workspace_mitigation_artifact_delete",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/artifact/<str:kind>/delete-all/",
|
||||||
|
workspace.AIWorkspaceDeleteArtifactList.as_view(),
|
||||||
|
name="ai_workspace_mitigation_artifact_delete_all",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/engage/share/",
|
||||||
|
workspace.AIWorkspaceEngageShare.as_view(),
|
||||||
|
name="ai_workspace_mitigation_engage_share",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/auto/",
|
||||||
|
workspace.AIWorkspaceAutoSettings.as_view(),
|
||||||
|
name="ai_workspace_mitigation_auto",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"ai/workspace/<str:type>/person/<uuid:person_id>/mitigation/<uuid:plan_id>/fundamentals/save/",
|
||||||
|
workspace.AIWorkspaceUpdateFundamentals.as_view(),
|
||||||
|
name="ai_workspace_mitigation_fundamentals_save",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"ai/<str:type>/",
|
"ai/<str:type>/",
|
||||||
ais.AIList.as_view(),
|
ais.AIList.as_view(),
|
||||||
@@ -88,7 +182,6 @@ urlpatterns = [
|
|||||||
ais.AIDelete.as_view(),
|
ais.AIDelete.as_view(),
|
||||||
name="ai_delete",
|
name="ai_delete",
|
||||||
),
|
),
|
||||||
|
|
||||||
# People
|
# People
|
||||||
path(
|
path(
|
||||||
"person/<str:type>/",
|
"person/<str:type>/",
|
||||||
@@ -110,7 +203,6 @@ urlpatterns = [
|
|||||||
people.PersonDelete.as_view(),
|
people.PersonDelete.as_view(),
|
||||||
name="person_delete",
|
name="person_delete",
|
||||||
),
|
),
|
||||||
|
|
||||||
# Groups
|
# Groups
|
||||||
path(
|
path(
|
||||||
"group/<str:type>/",
|
"group/<str:type>/",
|
||||||
@@ -132,7 +224,6 @@ urlpatterns = [
|
|||||||
groups.GroupDelete.as_view(),
|
groups.GroupDelete.as_view(),
|
||||||
name="group_delete",
|
name="group_delete",
|
||||||
),
|
),
|
||||||
|
|
||||||
# Personas
|
# Personas
|
||||||
path(
|
path(
|
||||||
"persona/<str:type>/",
|
"persona/<str:type>/",
|
||||||
@@ -154,7 +245,6 @@ urlpatterns = [
|
|||||||
personas.PersonaDelete.as_view(),
|
personas.PersonaDelete.as_view(),
|
||||||
name="persona_delete",
|
name="persona_delete",
|
||||||
),
|
),
|
||||||
|
|
||||||
# Manipulations
|
# Manipulations
|
||||||
path(
|
path(
|
||||||
"manipulation/<str:type>/",
|
"manipulation/<str:type>/",
|
||||||
@@ -198,19 +288,59 @@ urlpatterns = [
|
|||||||
name="session_delete",
|
name="session_delete",
|
||||||
),
|
),
|
||||||
# Identifiers
|
# Identifiers
|
||||||
path("person/<str:type>/identifiers/<str:person>/", identifiers.PersonIdentifierList.as_view(), name="person_identifiers"),
|
path(
|
||||||
path("person/<str:type>/identifiers/create/<str:person>", identifiers.PersonIdentifierCreate.as_view(), name="person_identifier_create"),
|
"person/<str:type>/identifiers/<str:person>/",
|
||||||
path("person/<str:type>/identifiers/update/<str:person>/<str:pk>/", identifiers.PersonIdentifierUpdate.as_view(), name="person_identifier_update"),
|
identifiers.PersonIdentifierList.as_view(),
|
||||||
path("person/<str:type>/identifiers/delete/<str:person>/<str:pk>/", identifiers.PersonIdentifierDelete.as_view(), name="person_identifier_delete"),
|
name="person_identifiers",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"person/<str:type>/identifiers/create/<str:person>",
|
||||||
|
identifiers.PersonIdentifierCreate.as_view(),
|
||||||
|
name="person_identifier_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"person/<str:type>/identifiers/update/<str:person>/<str:pk>/",
|
||||||
|
identifiers.PersonIdentifierUpdate.as_view(),
|
||||||
|
name="person_identifier_update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"person/<str:type>/identifiers/delete/<str:person>/<str:pk>/",
|
||||||
|
identifiers.PersonIdentifierDelete.as_view(),
|
||||||
|
name="person_identifier_delete",
|
||||||
|
),
|
||||||
# Messages
|
# Messages
|
||||||
path("session/<str:type>/messages/<str:session>/", messages.MessageList.as_view(), name="messages"),
|
path(
|
||||||
path("session/<str:type>/messages/create/<str:session>", messages.MessageCreate.as_view(), name="message_create"),
|
"session/<str:type>/messages/<str:session>/",
|
||||||
path("session/<str:type>/messages/update/<str:session>/<str:pk>/", messages.MessageUpdate.as_view(), name="message_update"),
|
messages.MessageList.as_view(),
|
||||||
path("session/<str:type>/messages/delete/<str:session>/<str:pk>/", messages.MessageDelete.as_view(), name="message_delete"),
|
name="messages",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/<str:type>/messages/create/<str:session>",
|
||||||
|
messages.MessageCreate.as_view(),
|
||||||
|
name="message_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/<str:type>/messages/update/<str:session>/<str:pk>/",
|
||||||
|
messages.MessageUpdate.as_view(),
|
||||||
|
name="message_update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"session/<str:type>/messages/delete/<str:session>/<str:pk>/",
|
||||||
|
messages.MessageDelete.as_view(),
|
||||||
|
name="message_delete",
|
||||||
|
),
|
||||||
# API
|
# API
|
||||||
# Queues
|
# Queues
|
||||||
path("api/v1/queue/message/accept/<str:message_id>/", queues.AcceptMessageAPI.as_view(), name="message_accept_api"),
|
path(
|
||||||
path("api/v1/queue/message/reject/<str:message_id>/", queues.RejectMessageAPI.as_view(), name="message_reject_api"),
|
"api/v1/queue/message/accept/<str:message_id>/",
|
||||||
|
queues.AcceptMessageAPI.as_view(),
|
||||||
|
name="message_accept_api",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"api/v1/queue/message/reject/<str:message_id>/",
|
||||||
|
queues.RejectMessageAPI.as_view(),
|
||||||
|
name="message_reject_api",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"queue/<str:type>/",
|
"queue/<str:type>/",
|
||||||
queues.QueueList.as_view(),
|
queues.QueueList.as_view(),
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
|
||||||
# Create a debug log to confirm script execution
|
# Create a debug log to confirm script execution
|
||||||
|
|
||||||
import sys
|
|
||||||
import django
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import django
|
||||||
|
|
||||||
LOG_PATH = "auth_debug.log"
|
LOG_PATH = "auth_debug.log"
|
||||||
|
|
||||||
|
|
||||||
def log(data):
|
def log(data):
|
||||||
with open(LOG_PATH, "a") as f:
|
with open(LOG_PATH, "a") as f:
|
||||||
f.write(f"{data}\n")
|
f.write(f"{data}\n")
|
||||||
|
|
||||||
|
|
||||||
# Set up Django environment
|
# Set up Django environment
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") # Adjust if needed
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") # Adjust if needed
|
||||||
django.setup()
|
django.setup()
|
||||||
@@ -18,11 +20,13 @@ django.setup()
|
|||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
def check_credentials(username, password):
|
def check_credentials(username, password):
|
||||||
"""Authenticate user via Django"""
|
"""Authenticate user via Django"""
|
||||||
user = authenticate(username=username, password=password)
|
user = authenticate(username=username, password=password)
|
||||||
return user is not None and user.is_active
|
return user is not None and user.is_active
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Process authentication requests from Prosody"""
|
"""Process authentication requests from Prosody"""
|
||||||
while True:
|
while True:
|
||||||
@@ -42,7 +46,9 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
command, username, domain = parts[:3]
|
command, username, domain = parts[:3]
|
||||||
password = ":".join(parts[3:]) if len(parts) > 3 else None # Reconstruct password
|
password = (
|
||||||
|
":".join(parts[3:]) if len(parts) > 3 else None
|
||||||
|
) # Reconstruct password
|
||||||
|
|
||||||
if command == "auth":
|
if command == "auth":
|
||||||
if password and check_credentials(username, password):
|
if password and check_credentials(username, password):
|
||||||
@@ -71,5 +77,6 @@ def main():
|
|||||||
log(f"Error: {str(e)}\n")
|
log(f"Error: {str(e)}\n")
|
||||||
print("0", flush=True) # Return failure for any error
|
print("0", flush=True) # Return failure for any error
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from django.urls import reverse
|
|||||||
from signalbot import Command, Context, SignalBot
|
from signalbot import Command, Context, SignalBot
|
||||||
|
|
||||||
from core.clients import ClientBase, signalapi
|
from core.clients import ClientBase, signalapi
|
||||||
from core.lib.prompts.functions import delete_messages, truncate_and_summarize
|
|
||||||
from core.messaging import ai, history, natural, replies, utils
|
from core.messaging import ai, history, natural, replies, utils
|
||||||
from core.models import Chat, Manipulation, PersonIdentifier, QueuedMessage
|
from core.models import Chat, Manipulation, PersonIdentifier, QueuedMessage
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
@@ -25,11 +24,90 @@ SIGNAL_PORT = 8080
|
|||||||
|
|
||||||
SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}"
|
SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nested(payload, path):
|
||||||
|
current = payload
|
||||||
|
for key in path:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
current = current.get(key)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_signal_attachment(entry):
|
||||||
|
return isinstance(entry, dict) and (
|
||||||
|
"id" in entry or "attachmentId" in entry or "contentType" in entry
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_attachment(entry):
|
||||||
|
attachment_id = entry.get("id") or entry.get("attachmentId")
|
||||||
|
if attachment_id is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": attachment_id,
|
||||||
|
"content_type": entry.get("contentType", "application/octet-stream"),
|
||||||
|
"filename": entry.get("filename") or str(attachment_id),
|
||||||
|
"size": entry.get("size") or 0,
|
||||||
|
"width": entry.get("width"),
|
||||||
|
"height": entry.get("height"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_attachments(raw_payload):
|
||||||
|
envelope = raw_payload.get("envelope", {})
|
||||||
|
candidate_paths = [
|
||||||
|
("dataMessage", "attachments"),
|
||||||
|
("syncMessage", "sentMessage", "attachments"),
|
||||||
|
("syncMessage", "editMessage", "dataMessage", "attachments"),
|
||||||
|
]
|
||||||
|
results = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for path in candidate_paths:
|
||||||
|
found = _get_nested(envelope, path)
|
||||||
|
if not isinstance(found, list):
|
||||||
|
continue
|
||||||
|
for entry in found:
|
||||||
|
normalized = _normalize_attachment(entry)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
key = str(normalized["id"])
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
results.append(normalized)
|
||||||
|
|
||||||
|
# Fallback: scan for attachment-shaped lists under envelope.
|
||||||
|
if not results:
|
||||||
|
stack = [envelope]
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for value in node.values():
|
||||||
|
stack.append(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
if node and all(_looks_like_signal_attachment(item) for item in node):
|
||||||
|
for entry in node:
|
||||||
|
normalized = _normalize_attachment(entry)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
key = str(normalized["id"])
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
results.append(normalized)
|
||||||
|
else:
|
||||||
|
for value in node:
|
||||||
|
stack.append(value)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
class NewSignalBot(SignalBot):
|
class NewSignalBot(SignalBot):
|
||||||
def __init__(self, ur, service, config):
|
def __init__(self, ur, service, config):
|
||||||
self.ur = ur
|
self.ur = ur
|
||||||
self.service = service
|
self.service = service
|
||||||
self.signal_rest = config["signal_service"] # keep your own copy
|
self.signal_rest = config["signal_service"] # keep your own copy
|
||||||
self.phone_number = config["phone_number"]
|
self.phone_number = config["phone_number"]
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.log = logs.get_logger("signalI")
|
self.log = logs.get_logger("signalI")
|
||||||
@@ -46,7 +124,9 @@ class NewSignalBot(SignalBot):
|
|||||||
try:
|
try:
|
||||||
resp = await session.get(uri)
|
resp = await session.get(uri)
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
self.log.error(f"contacts lookup failed: {resp.status} {await resp.text()}")
|
self.log.error(
|
||||||
|
f"contacts lookup failed: {resp.status} {await resp.text()}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
contacts_data = await resp.json()
|
contacts_data = await resp.json()
|
||||||
@@ -95,6 +175,7 @@ class HandleMessage(Command):
|
|||||||
self.ur = ur
|
self.ur = ur
|
||||||
self.service = service
|
self.service = service
|
||||||
return super().__init__(*args, **kwargs)
|
return super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
async def handle(self, c: Context):
|
async def handle(self, c: Context):
|
||||||
msg = {
|
msg = {
|
||||||
"source": c.message.source,
|
"source": c.message.source,
|
||||||
@@ -106,10 +187,15 @@ class HandleMessage(Command):
|
|||||||
"group": c.message.group,
|
"group": c.message.group,
|
||||||
"reaction": c.message.reaction,
|
"reaction": c.message.reaction,
|
||||||
"mentions": c.message.mentions,
|
"mentions": c.message.mentions,
|
||||||
"raw_message": c.message.raw_message
|
"raw_message": c.message.raw_message,
|
||||||
}
|
}
|
||||||
raw = json.loads(c.message.raw_message)
|
raw = json.loads(c.message.raw_message)
|
||||||
dest = raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("destinationUuid")
|
dest = (
|
||||||
|
raw.get("envelope", {})
|
||||||
|
.get("syncMessage", {})
|
||||||
|
.get("sentMessage", {})
|
||||||
|
.get("destinationUuid")
|
||||||
|
)
|
||||||
|
|
||||||
account = raw.get("account", "")
|
account = raw.get("account", "")
|
||||||
source_name = raw.get("envelope", {}).get("sourceName", "")
|
source_name = raw.get("envelope", {}).get("sourceName", "")
|
||||||
@@ -125,9 +211,9 @@ class HandleMessage(Command):
|
|||||||
is_from_bot = source_uuid == c.bot.bot_uuid
|
is_from_bot = source_uuid == c.bot.bot_uuid
|
||||||
is_to_bot = dest == c.bot.bot_uuid or dest is None
|
is_to_bot = dest == c.bot.bot_uuid or dest is None
|
||||||
|
|
||||||
reply_to_self = same_recipient and is_from_bot # Reply
|
reply_to_self = same_recipient and is_from_bot # Reply
|
||||||
reply_to_others = is_to_bot and not same_recipient # Reply
|
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
|
is_outgoing_message = is_from_bot and not is_to_bot # Do not reply
|
||||||
|
|
||||||
# Determine the identifier to use
|
# Determine the identifier to use
|
||||||
identifier_uuid = dest if is_from_bot else source_uuid
|
identifier_uuid = dest if is_from_bot else source_uuid
|
||||||
@@ -135,20 +221,8 @@ class HandleMessage(Command):
|
|||||||
log.warning("No Signal identifier available for message routing.")
|
log.warning("No Signal identifier available for message routing.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle attachments
|
# Handle attachments across multiple Signal payload variants.
|
||||||
attachments = raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("attachments", [])
|
attachment_list = _extract_attachments(raw)
|
||||||
if not attachments:
|
|
||||||
attachments = raw.get("envelope", {}).get("dataMessage", {}).get("attachments", [])
|
|
||||||
attachment_list = []
|
|
||||||
for attachment in attachments:
|
|
||||||
attachment_list.append({
|
|
||||||
"id": attachment["id"],
|
|
||||||
"content_type": attachment["contentType"],
|
|
||||||
"filename": attachment["filename"],
|
|
||||||
"size": attachment["size"],
|
|
||||||
"width": attachment.get("width"),
|
|
||||||
"height": attachment.get("height"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get users/person identifiers for this Signal sender/recipient.
|
# Get users/person identifiers for this Signal sender/recipient.
|
||||||
identifiers = await sync_to_async(list)(
|
identifiers = await sync_to_async(list)(
|
||||||
@@ -160,9 +234,16 @@ class HandleMessage(Command):
|
|||||||
xmpp_attachments = []
|
xmpp_attachments = []
|
||||||
|
|
||||||
# Asynchronously fetch all attachments
|
# Asynchronously fetch all attachments
|
||||||
tasks = [signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list]
|
|
||||||
fetched_attachments = await asyncio.gather(*tasks)
|
|
||||||
log.info(f"ATTACHMENT LIST {attachment_list}")
|
log.info(f"ATTACHMENT LIST {attachment_list}")
|
||||||
|
if attachment_list:
|
||||||
|
tasks = [
|
||||||
|
signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list
|
||||||
|
]
|
||||||
|
fetched_attachments = await asyncio.gather(*tasks)
|
||||||
|
else:
|
||||||
|
envelope = raw.get("envelope", {})
|
||||||
|
log.info(f"No attachments found. Envelope keys: {list(envelope.keys())}")
|
||||||
|
fetched_attachments = []
|
||||||
|
|
||||||
for fetched, att in zip(fetched_attachments, attachment_list):
|
for fetched, att in zip(fetched_attachments, attachment_list):
|
||||||
if not fetched:
|
if not fetched:
|
||||||
@@ -170,12 +251,14 @@ class HandleMessage(Command):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Attach fetched file to XMPP
|
# Attach fetched file to XMPP
|
||||||
xmpp_attachments.append({
|
xmpp_attachments.append(
|
||||||
"content": fetched["content"],
|
{
|
||||||
"content_type": fetched["content_type"],
|
"content": fetched["content"],
|
||||||
"filename": fetched["filename"],
|
"content_type": fetched["content_type"],
|
||||||
"size": fetched["size"],
|
"filename": fetched["filename"],
|
||||||
})
|
"size": fetched["size"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Forward incoming Signal messages to XMPP and apply mutate rules.
|
# Forward incoming Signal messages to XMPP and apply mutate rules.
|
||||||
for identifier in identifiers:
|
for identifier in identifiers:
|
||||||
@@ -200,7 +283,9 @@ class HandleMessage(Command):
|
|||||||
)
|
)
|
||||||
log.info("Running Signal mutate prompt")
|
log.info("Running Signal mutate prompt")
|
||||||
result = await ai.run_prompt(prompt, manip.ai)
|
result = await ai.run_prompt(prompt, manip.ai)
|
||||||
log.info(f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP.")
|
log.info(
|
||||||
|
f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
|
||||||
|
)
|
||||||
await self.ur.xmpp.client.send_from_external(
|
await self.ur.xmpp.client.send_from_external(
|
||||||
user,
|
user,
|
||||||
identifier,
|
identifier,
|
||||||
@@ -209,7 +294,9 @@ class HandleMessage(Command):
|
|||||||
attachments=xmpp_attachments,
|
attachments=xmpp_attachments,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.info(f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP.")
|
log.info(
|
||||||
|
f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
|
||||||
|
)
|
||||||
await self.ur.xmpp.client.send_from_external(
|
await self.ur.xmpp.client.send_from_external(
|
||||||
user,
|
user,
|
||||||
identifier,
|
identifier,
|
||||||
@@ -219,9 +306,7 @@ class HandleMessage(Command):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Permission checks
|
# TODO: Permission checks
|
||||||
manips = await sync_to_async(list)(
|
manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True))
|
||||||
Manipulation.objects.filter(enabled=True)
|
|
||||||
)
|
|
||||||
session_cache = {}
|
session_cache = {}
|
||||||
stored_messages = set()
|
stored_messages = set()
|
||||||
for manip in manips:
|
for manip in manips:
|
||||||
@@ -233,7 +318,9 @@ class HandleMessage(Command):
|
|||||||
person__in=manip.group.people.all(),
|
person__in=manip.group.people.all(),
|
||||||
)
|
)
|
||||||
except PersonIdentifier.DoesNotExist:
|
except PersonIdentifier.DoesNotExist:
|
||||||
log.warning(f"{manip.name}: Message from unknown identifier {identifier_uuid}.")
|
log.warning(
|
||||||
|
f"{manip.name}: Message from unknown identifier {identifier_uuid}."
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find/create ChatSession once per user/person.
|
# Find/create ChatSession once per user/person.
|
||||||
@@ -241,7 +328,9 @@ class HandleMessage(Command):
|
|||||||
if session_key in session_cache:
|
if session_key in session_cache:
|
||||||
chat_session = session_cache[session_key]
|
chat_session = session_cache[session_key]
|
||||||
else:
|
else:
|
||||||
chat_session = await history.get_chat_session(manip.user, person_identifier)
|
chat_session = await history.get_chat_session(
|
||||||
|
manip.user, person_identifier
|
||||||
|
)
|
||||||
session_cache[session_key] = chat_session
|
session_cache[session_key] = chat_session
|
||||||
|
|
||||||
# Store each incoming/outgoing event once per session.
|
# Store each incoming/outgoing event once per session.
|
||||||
@@ -270,10 +359,7 @@ class HandleMessage(Command):
|
|||||||
elif manip.mode in ["active", "notify", "instant"]:
|
elif manip.mode in ["active", "notify", "instant"]:
|
||||||
await utils.update_last_interaction(chat_session)
|
await utils.update_last_interaction(chat_session)
|
||||||
prompt = replies.generate_reply_prompt(
|
prompt = replies.generate_reply_prompt(
|
||||||
msg,
|
msg, person_identifier.person, manip, chat_history
|
||||||
person_identifier.person,
|
|
||||||
manip,
|
|
||||||
chat_history
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info("Running context prompt")
|
log.info("Running context prompt")
|
||||||
@@ -307,14 +393,13 @@ class HandleMessage(Command):
|
|||||||
custom_author="BOT",
|
custom_author="BOT",
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_messages(existing_queue)
|
await history.delete_queryset(existing_queue)
|
||||||
qm = await history.store_own_message(
|
qm = await history.store_own_message(
|
||||||
session=chat_session,
|
session=chat_session,
|
||||||
text=result,
|
text=result,
|
||||||
ts=ts + 1,
|
ts=ts + 1,
|
||||||
manip=manip,
|
manip=manip,
|
||||||
queue=True,
|
queue=True,
|
||||||
|
|
||||||
)
|
)
|
||||||
accept = reverse(
|
accept = reverse(
|
||||||
"message_accept_api", kwargs={"message_id": qm.id}
|
"message_accept_api", kwargs={"message_id": qm.id}
|
||||||
@@ -333,9 +418,6 @@ class HandleMessage(Command):
|
|||||||
else:
|
else:
|
||||||
log.error(f"Mode {manip.mode} is not implemented")
|
log.error(f"Mode {manip.mode} is not implemented")
|
||||||
|
|
||||||
# Manage truncation & summarization
|
|
||||||
await truncate_and_summarize(chat_session, manip.ai)
|
|
||||||
|
|
||||||
await sync_to_async(Chat.objects.update_or_create)(
|
await sync_to_async(Chat.objects.update_or_create)(
|
||||||
source_uuid=source_uuid,
|
source_uuid=source_uuid,
|
||||||
defaults={
|
defaults={
|
||||||
@@ -353,9 +435,10 @@ class SignalClient(ClientBase):
|
|||||||
ur,
|
ur,
|
||||||
self.service,
|
self.service,
|
||||||
{
|
{
|
||||||
"signal_service": SIGNAL_URL,
|
"signal_service": SIGNAL_URL,
|
||||||
"phone_number": "+447490296227",
|
"phone_number": "+447490296227",
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
self.client.register(HandleMessage(self.ur, self.service))
|
self.client.register(HandleMessage(self.ur, self.service))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from rest_framework import status
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
import orjson
|
|
||||||
from django.conf import settings
|
|
||||||
import aiohttp
|
|
||||||
import base64
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import orjson
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
|
||||||
async def start_typing(uuid):
|
async def start_typing(uuid):
|
||||||
@@ -18,6 +18,7 @@ async def start_typing(uuid):
|
|||||||
async with session.put(url, json=data) as response:
|
async with session.put(url, json=data) as response:
|
||||||
return await response.text() # Optional: Return response content
|
return await response.text() # Optional: Return response content
|
||||||
|
|
||||||
|
|
||||||
async def stop_typing(uuid):
|
async def stop_typing(uuid):
|
||||||
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
||||||
url = f"{base}/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
|
url = f"{base}/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
|
||||||
@@ -27,6 +28,7 @@ async def stop_typing(uuid):
|
|||||||
async with session.delete(url, json=data) as response:
|
async with session.delete(url, json=data) as response:
|
||||||
return await response.text() # Optional: Return response content
|
return await response.text() # Optional: Return response content
|
||||||
|
|
||||||
|
|
||||||
async def download_and_encode_base64(file_url, filename, content_type):
|
async def download_and_encode_base64(file_url, filename, content_type):
|
||||||
"""
|
"""
|
||||||
Downloads a file from a given URL asynchronously, converts it to Base64,
|
Downloads a file from a given URL asynchronously, converts it to Base64,
|
||||||
@@ -51,12 +53,15 @@ async def download_and_encode_base64(file_url, filename, content_type):
|
|||||||
base64_encoded = base64.b64encode(file_data).decode("utf-8")
|
base64_encoded = base64.b64encode(file_data).decode("utf-8")
|
||||||
|
|
||||||
# Format according to Signal's expected structure
|
# Format according to Signal's expected structure
|
||||||
return f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
return (
|
||||||
|
f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
||||||
|
)
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
||||||
"""
|
"""
|
||||||
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
||||||
@@ -75,11 +80,14 @@ async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
|||||||
data = {
|
data = {
|
||||||
"recipients": [recipient_uuid],
|
"recipients": [recipient_uuid],
|
||||||
"number": settings.SIGNAL_NUMBER,
|
"number": settings.SIGNAL_NUMBER,
|
||||||
"base64_attachments": []
|
"base64_attachments": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Asynchronously download and encode all attachments
|
# Asynchronously download and encode all attachments
|
||||||
tasks = [download_and_encode_base64(att["url"], att["filename"], att["content_type"]) for att in attachments]
|
tasks = [
|
||||||
|
download_and_encode_base64(att["url"], att["filename"], att["content_type"])
|
||||||
|
for att in attachments
|
||||||
|
]
|
||||||
encoded_attachments = await asyncio.gather(*tasks)
|
encoded_attachments = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# Filter out failed downloads (None values)
|
# Filter out failed downloads (None values)
|
||||||
@@ -87,7 +95,7 @@ async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
|||||||
|
|
||||||
# Remove the message body if it only contains an attachment link
|
# Remove the message body if it only contains an attachment link
|
||||||
if text and (text.strip() in [att["url"] for att in attachments]):
|
if text and (text.strip() in [att["url"] for att in attachments]):
|
||||||
#log.info("Removing message body since it only contains an attachment link.")
|
# log.info("Removing message body since it only contains an attachment link.")
|
||||||
text = None # Don't send the link as text
|
text = None # Don't send the link as text
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
@@ -103,6 +111,7 @@ async def send_message_raw(recipient_uuid, text=None, attachments=[]):
|
|||||||
return ts if ts else False
|
return ts if ts else False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def fetch_signal_attachment(attachment_id):
|
async def fetch_signal_attachment(attachment_id):
|
||||||
"""
|
"""
|
||||||
Asynchronously fetches an attachment from Signal.
|
Asynchronously fetches an attachment from Signal.
|
||||||
@@ -111,7 +120,7 @@ async def fetch_signal_attachment(attachment_id):
|
|||||||
attachment_id (str): The Signal attachment ID.
|
attachment_id (str): The Signal attachment ID.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict | None:
|
dict | None:
|
||||||
{
|
{
|
||||||
"content": <binary file data>,
|
"content": <binary file data>,
|
||||||
"content_type": <MIME type>,
|
"content_type": <MIME type>,
|
||||||
@@ -128,7 +137,9 @@ async def fetch_signal_attachment(attachment_id):
|
|||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
return None # Failed request
|
return None # Failed request
|
||||||
|
|
||||||
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
content_type = response.headers.get(
|
||||||
|
"Content-Type", "application/octet-stream"
|
||||||
|
)
|
||||||
content = await response.read()
|
content = await response.read()
|
||||||
size = int(response.headers.get("Content-Length", len(content)))
|
size = int(response.headers.get("Content-Length", len(content)))
|
||||||
|
|
||||||
@@ -150,7 +161,6 @@ async def fetch_signal_attachment(attachment_id):
|
|||||||
return None # Network error
|
return None # Network error
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def download_and_encode_base64_sync(file_url, filename, content_type):
|
def download_and_encode_base64_sync(file_url, filename, content_type):
|
||||||
"""
|
"""
|
||||||
Downloads a file from a given URL, converts it to Base64, and returns it in Signal's expected format.
|
Downloads a file from a given URL, converts it to Base64, and returns it in Signal's expected format.
|
||||||
@@ -173,7 +183,7 @@ def download_and_encode_base64_sync(file_url, filename, content_type):
|
|||||||
# Format according to Signal's expected structure
|
# Format according to Signal's expected structure
|
||||||
return f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
return f"data:{content_type};filename={filename};base64,{base64_encoded}"
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
#log.error(f"Failed to download file: {file_url}, error: {e}")
|
# log.error(f"Failed to download file: {file_url}, error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -193,18 +203,20 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=[]):
|
|||||||
data = {
|
data = {
|
||||||
"recipients": [recipient_uuid],
|
"recipients": [recipient_uuid],
|
||||||
"number": settings.SIGNAL_NUMBER,
|
"number": settings.SIGNAL_NUMBER,
|
||||||
"base64_attachments": []
|
"base64_attachments": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convert attachments to Base64
|
# Convert attachments to Base64
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
base64_data = download_and_encode_base64_sync(att["url"], att["filename"], att["content_type"])
|
base64_data = download_and_encode_base64_sync(
|
||||||
|
att["url"], att["filename"], att["content_type"]
|
||||||
|
)
|
||||||
if base64_data:
|
if base64_data:
|
||||||
data["base64_attachments"].append(base64_data)
|
data["base64_attachments"].append(base64_data)
|
||||||
|
|
||||||
# Remove the message body if it only contains an attachment link
|
# Remove the message body if it only contains an attachment link
|
||||||
if text and (text.strip() in [att["url"] for att in attachments]):
|
if text and (text.strip() in [att["url"] for att in attachments]):
|
||||||
#log.info("Removing message body since it only contains an attachment link.")
|
# log.info("Removing message body since it only contains an attachment link.")
|
||||||
text = None # Don't send the link as text
|
text = None # Don't send the link as text
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
@@ -214,10 +226,12 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=[]):
|
|||||||
response = requests.post(url, json=data, timeout=10)
|
response = requests.post(url, json=data, timeout=10)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
#log.error(f"Failed to send Signal message: {e}")
|
# log.error(f"Failed to send Signal message: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if response.status_code == status.HTTP_201_CREATED: # Signal server returns 201 on success
|
if (
|
||||||
|
response.status_code == status.HTTP_201_CREATED
|
||||||
|
): # Signal server returns 201 on success
|
||||||
try:
|
try:
|
||||||
ts = orjson.loads(response.text).get("timestamp", None)
|
ts = orjson.loads(response.text).get("timestamp", None)
|
||||||
return ts if ts else False
|
return ts if ts else False
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
from core.clients import ClientBase
|
|
||||||
from django.conf import settings
|
|
||||||
from slixmpp.componentxmpp import ComponentXMPP
|
|
||||||
from django.conf import settings
|
|
||||||
from core.models import User, Person, PersonIdentifier, ChatSession, Manipulation
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from django.utils.timezone import now
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from core.clients import signalapi
|
|
||||||
from slixmpp.xmlstream import register_stanza_plugin
|
|
||||||
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Paused, Inactive, Gone
|
|
||||||
from slixmpp.stanza import Message
|
|
||||||
from slixmpp.xmlstream.stanzabase import ET
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from core.messaging import history
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from slixmpp.componentxmpp import ComponentXMPP
|
||||||
|
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Gone, Inactive, Paused
|
||||||
|
from slixmpp.stanza import Message
|
||||||
|
from slixmpp.xmlstream import register_stanza_plugin
|
||||||
|
from slixmpp.xmlstream.stanzabase import ET
|
||||||
|
|
||||||
|
from core.clients import ClientBase, signalapi
|
||||||
|
from core.messaging import ai, history, replies, utils
|
||||||
|
from core.models import (
|
||||||
|
ChatSession,
|
||||||
|
Manipulation,
|
||||||
|
PatternMitigationAutoSettings,
|
||||||
|
PatternMitigationGame,
|
||||||
|
PatternMitigationPlan,
|
||||||
|
PatternMitigationRule,
|
||||||
|
Person,
|
||||||
|
PersonIdentifier,
|
||||||
|
User,
|
||||||
|
WorkspaceConversation,
|
||||||
|
)
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
from core.messaging import replies, utils, ai
|
|
||||||
|
|
||||||
|
|
||||||
class XMPPComponent(ComponentXMPP):
|
class XMPPComponent(ComponentXMPP):
|
||||||
@@ -51,7 +61,9 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.add_event_handler("presence_subscribed", self.on_presence_subscribed)
|
self.add_event_handler("presence_subscribed", self.on_presence_subscribed)
|
||||||
self.add_event_handler("presence_unsubscribe", self.on_presence_unsubscribe)
|
self.add_event_handler("presence_unsubscribe", self.on_presence_unsubscribe)
|
||||||
self.add_event_handler("presence_unsubscribed", self.on_presence_unsubscribed)
|
self.add_event_handler("presence_unsubscribed", self.on_presence_unsubscribed)
|
||||||
self.add_event_handler("roster_subscription_request", self.on_roster_subscription_request)
|
self.add_event_handler(
|
||||||
|
"roster_subscription_request", self.on_roster_subscription_request
|
||||||
|
)
|
||||||
|
|
||||||
# Chat state handlers
|
# Chat state handlers
|
||||||
self.add_event_handler("chatstate_active", self.on_chatstate_active)
|
self.add_event_handler("chatstate_active", self.on_chatstate_active)
|
||||||
@@ -73,13 +85,15 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
def get_identifier(self, msg):
|
def get_identifier(self, msg):
|
||||||
# Extract sender JID (full format: user@domain/resource)
|
# Extract sender JID (full format: user@domain/resource)
|
||||||
sender_jid = str(msg["from"])
|
sender_jid = str(msg["from"])
|
||||||
|
|
||||||
# Split into username@domain and optional resource
|
# Split into username@domain and optional resource
|
||||||
sender_parts = sender_jid.split("/", 1)
|
sender_parts = sender_jid.split("/", 1)
|
||||||
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
||||||
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
||||||
|
|
||||||
sender_resource = sender_parts[1] if len(sender_parts) > 1 else None # Extract resource if present
|
sender_resource = (
|
||||||
|
sender_parts[1] if len(sender_parts) > 1 else None
|
||||||
|
) # Extract resource if present
|
||||||
|
|
||||||
# Extract recipient JID (should match component JID format)
|
# Extract recipient JID (should match component JID format)
|
||||||
recipient_jid = str(msg["to"])
|
recipient_jid = str(msg["to"])
|
||||||
@@ -100,7 +114,6 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
person_name = recipient_username.title()
|
person_name = recipient_username.title()
|
||||||
service = None
|
service = None
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Lookup user in Django
|
# Lookup user in Django
|
||||||
self.log.info(f"User {sender_username}")
|
self.log.info(f"User {sender_username}")
|
||||||
@@ -112,22 +125,255 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
# Ensure a PersonIdentifier exists for this user, person, and service
|
# Ensure a PersonIdentifier exists for this user, person, and service
|
||||||
self.log.info(f"Identifier {service}")
|
self.log.info(f"Identifier {service}")
|
||||||
identifier = PersonIdentifier.objects.get(user=user, person=person, service=service)
|
identifier = PersonIdentifier.objects.get(
|
||||||
|
user=user, person=person, service=service
|
||||||
|
)
|
||||||
return identifier
|
return identifier
|
||||||
|
except Exception as e:
|
||||||
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
|
self.log.error(f"Failed to resolve identifier from XMPP message: {e}")
|
||||||
# If any lookup fails, reject the subscription
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_workspace_conversation(self, user, person):
|
||||||
|
conversation, _ = WorkspaceConversation.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
platform_type="signal",
|
||||||
|
title=f"{person.name} Workspace",
|
||||||
|
defaults={"platform_thread_id": str(person.id)},
|
||||||
|
)
|
||||||
|
conversation.participants.add(person)
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
def _get_or_create_plan(self, user, person):
|
||||||
|
conversation = self._get_workspace_conversation(user, person)
|
||||||
|
plan = conversation.mitigation_plans.order_by("-updated_at").first()
|
||||||
|
if plan is None:
|
||||||
|
plan = PatternMitigationPlan.objects.create(
|
||||||
|
user=user,
|
||||||
|
conversation=conversation,
|
||||||
|
title=f"{person.name} Pattern Mitigation",
|
||||||
|
objective="Mitigate repeated friction loops.",
|
||||||
|
fundamental_items=[],
|
||||||
|
creation_mode="guided",
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
PatternMitigationRule.objects.create(
|
||||||
|
user=user,
|
||||||
|
plan=plan,
|
||||||
|
title="Safety Before Analysis",
|
||||||
|
content="Prioritize de-escalation before analysis.",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
PatternMitigationGame.objects.create(
|
||||||
|
user=user,
|
||||||
|
plan=plan,
|
||||||
|
title="Two-Turn Pause",
|
||||||
|
instructions="Use two short turns then pause with a return time.",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
async def _handle_mitigation_command(self, sender_user, body, sym):
|
||||||
|
def parse_parts(raw):
|
||||||
|
return [part.strip() for part in raw.split("|")]
|
||||||
|
|
||||||
|
command = body.strip()
|
||||||
|
if command == ".mitigation help":
|
||||||
|
sym(
|
||||||
|
"Mitigation commands: "
|
||||||
|
".mitigation list | "
|
||||||
|
".mitigation show <person> | "
|
||||||
|
".mitigation rule-add <person>|<title>|<content> | "
|
||||||
|
".mitigation rule-del <person>|<title> | "
|
||||||
|
".mitigation game-add <person>|<title>|<instructions> | "
|
||||||
|
".mitigation game-del <person>|<title> | "
|
||||||
|
".mitigation auto <person>|on|off | "
|
||||||
|
".mitigation auto-status <person>"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command == ".mitigation list":
|
||||||
|
plans = await sync_to_async(list)(
|
||||||
|
PatternMitigationPlan.objects.filter(user=sender_user)
|
||||||
|
.select_related("conversation")
|
||||||
|
.order_by("-updated_at")[:15]
|
||||||
|
)
|
||||||
|
if not plans:
|
||||||
|
sym("No mitigation plans found.")
|
||||||
|
return True
|
||||||
|
rows = []
|
||||||
|
for plan in plans:
|
||||||
|
person_name = (
|
||||||
|
plan.conversation.participants.order_by("name").first().name
|
||||||
|
if plan.conversation.participants.exists()
|
||||||
|
else "Unknown"
|
||||||
|
)
|
||||||
|
rows.append(f"{person_name}: {plan.title}")
|
||||||
|
sym("Plans: " + " | ".join(rows))
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation show "):
|
||||||
|
person_name = command.replace(".mitigation show ", "", 1).strip().title()
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||||
|
rule_count = await sync_to_async(plan.rules.count)()
|
||||||
|
game_count = await sync_to_async(plan.games.count)()
|
||||||
|
sym(f"{person.name}: {plan.title} | rules={rule_count} games={game_count}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation rule-add "):
|
||||||
|
payload = command.replace(".mitigation rule-add ", "", 1)
|
||||||
|
parts = parse_parts(payload)
|
||||||
|
if len(parts) < 3:
|
||||||
|
sym("Usage: .mitigation rule-add <person>|<title>|<content>")
|
||||||
|
return True
|
||||||
|
person_name, title, content = parts[0].title(), parts[1], "|".join(parts[2:])
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||||
|
await sync_to_async(PatternMitigationRule.objects.create)(
|
||||||
|
user=sender_user,
|
||||||
|
plan=plan,
|
||||||
|
title=title[:255],
|
||||||
|
content=content,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
sym("Rule added.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation rule-del "):
|
||||||
|
payload = command.replace(".mitigation rule-del ", "", 1)
|
||||||
|
parts = parse_parts(payload)
|
||||||
|
if len(parts) < 2:
|
||||||
|
sym("Usage: .mitigation rule-del <person>|<title>")
|
||||||
|
return True
|
||||||
|
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||||
|
deleted, _ = await sync_to_async(
|
||||||
|
lambda: PatternMitigationRule.objects.filter(
|
||||||
|
user=sender_user,
|
||||||
|
plan=plan,
|
||||||
|
title__iexact=title,
|
||||||
|
).delete()
|
||||||
|
)()
|
||||||
|
sym("Rule deleted." if deleted else "Rule not found.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation game-add "):
|
||||||
|
payload = command.replace(".mitigation game-add ", "", 1)
|
||||||
|
parts = parse_parts(payload)
|
||||||
|
if len(parts) < 3:
|
||||||
|
sym("Usage: .mitigation game-add <person>|<title>|<instructions>")
|
||||||
|
return True
|
||||||
|
person_name, title, content = parts[0].title(), parts[1], "|".join(parts[2:])
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||||
|
await sync_to_async(PatternMitigationGame.objects.create)(
|
||||||
|
user=sender_user,
|
||||||
|
plan=plan,
|
||||||
|
title=title[:255],
|
||||||
|
instructions=content,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
sym("Game added.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation game-del "):
|
||||||
|
payload = command.replace(".mitigation game-del ", "", 1)
|
||||||
|
parts = parse_parts(payload)
|
||||||
|
if len(parts) < 2:
|
||||||
|
sym("Usage: .mitigation game-del <person>|<title>")
|
||||||
|
return True
|
||||||
|
person_name, title = parts[0].title(), "|".join(parts[1:])
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
|
||||||
|
deleted, _ = await sync_to_async(
|
||||||
|
lambda: PatternMitigationGame.objects.filter(
|
||||||
|
user=sender_user,
|
||||||
|
plan=plan,
|
||||||
|
title__iexact=title,
|
||||||
|
).delete()
|
||||||
|
)()
|
||||||
|
sym("Game deleted." if deleted else "Game not found.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation auto "):
|
||||||
|
payload = command.replace(".mitigation auto ", "", 1)
|
||||||
|
parts = parse_parts(payload)
|
||||||
|
if len(parts) < 2:
|
||||||
|
sym("Usage: .mitigation auto <person>|on|off")
|
||||||
|
return True
|
||||||
|
person_name, state = parts[0].title(), parts[1].lower()
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
conversation = await sync_to_async(self._get_workspace_conversation)(sender_user, person)
|
||||||
|
auto_obj, _ = await sync_to_async(PatternMitigationAutoSettings.objects.get_or_create)(
|
||||||
|
user=sender_user,
|
||||||
|
conversation=conversation,
|
||||||
|
)
|
||||||
|
auto_obj.enabled = state in {"on", "true", "1", "yes"}
|
||||||
|
await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"])
|
||||||
|
sym(f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if command.startswith(".mitigation auto-status "):
|
||||||
|
person_name = command.replace(".mitigation auto-status ", "", 1).strip().title()
|
||||||
|
person = await sync_to_async(
|
||||||
|
lambda: Person.objects.filter(user=sender_user, name__iexact=person_name).first()
|
||||||
|
)()
|
||||||
|
if not person:
|
||||||
|
sym("Unknown person.")
|
||||||
|
return True
|
||||||
|
conversation = await sync_to_async(self._get_workspace_conversation)(sender_user, person)
|
||||||
|
auto_obj, _ = await sync_to_async(PatternMitigationAutoSettings.objects.get_or_create)(
|
||||||
|
user=sender_user,
|
||||||
|
conversation=conversation,
|
||||||
|
)
|
||||||
|
sym(
|
||||||
|
f"{person.name}: auto={'on' if auto_obj.enabled else 'off'}, "
|
||||||
|
f"pattern={'on' if auto_obj.auto_pattern_recognition else 'off'}, "
|
||||||
|
f"corrections={'on' if auto_obj.auto_create_corrections else 'off'}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def update_roster(self, jid, name=None):
|
def update_roster(self, jid, name=None):
|
||||||
"""
|
"""
|
||||||
Adds or updates a user in the roster.
|
Adds or updates a user in the roster.
|
||||||
"""
|
"""
|
||||||
iq = self.Iq()
|
iq = self.Iq()
|
||||||
iq['type'] = 'set'
|
iq["type"] = "set"
|
||||||
iq['roster']['items'] = {jid: {'name': name or jid}}
|
iq["roster"]["items"] = {jid: {"name": name or jid}}
|
||||||
|
|
||||||
iq.send()
|
iq.send()
|
||||||
self.log.info(f"Updated roster: Added {jid} ({name})")
|
self.log.info(f"Updated roster: Added {jid} ({name})")
|
||||||
|
|
||||||
@@ -171,7 +417,6 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
identifier = self.get_identifier(msg)
|
identifier = self.get_identifier(msg)
|
||||||
|
|
||||||
|
|
||||||
def on_presence_available(self, pres):
|
def on_presence_available(self, pres):
|
||||||
"""
|
"""
|
||||||
Handle when a user becomes available.
|
Handle when a user becomes available.
|
||||||
@@ -214,10 +459,12 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
Accept only if the recipient has a contact matching the sender.
|
Accept only if the recipient has a contact matching the sender.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sender_jid = str(pres['from']).split('/')[0] # Bare JID (user@domain)
|
sender_jid = str(pres["from"]).split("/")[0] # Bare JID (user@domain)
|
||||||
recipient_jid = str(pres['to']).split('/')[0]
|
recipient_jid = str(pres["to"]).split("/")[0]
|
||||||
|
|
||||||
self.log.info(f"Received subscription request from {sender_jid} to {recipient_jid}")
|
self.log.info(
|
||||||
|
f"Received subscription request from {sender_jid} to {recipient_jid}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract sender and recipient usernames
|
# Extract sender and recipient usernames
|
||||||
@@ -248,7 +495,9 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
# Accept the subscription
|
# Accept the subscription
|
||||||
self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid)
|
self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid)
|
||||||
self.log.info(f"Accepted subscription from {sender_jid}, sent from {component_jid}")
|
self.log.info(
|
||||||
|
f"Accepted subscription from {sender_jid}, sent from {component_jid}"
|
||||||
|
)
|
||||||
|
|
||||||
# Send a presence request **from the recipient to the sender** (ASKS THEM TO ACCEPT BACK)
|
# Send a presence request **from the recipient to the sender** (ASKS THEM TO ACCEPT BACK)
|
||||||
# self.send_presence(ptype="subscribe", pto=sender_jid, pfrom=component_jid)
|
# self.send_presence(ptype="subscribe", pto=sender_jid, pfrom=component_jid)
|
||||||
@@ -262,16 +511,16 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.send_presence(ptype="available", pto=sender_jid, pfrom=component_jid)
|
self.send_presence(ptype="available", pto=sender_jid, pfrom=component_jid)
|
||||||
self.log.info(f"Sent presence update from {component_jid} to {sender_jid}")
|
self.log.info(f"Sent presence update from {component_jid} to {sender_jid}")
|
||||||
|
|
||||||
|
|
||||||
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
|
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
|
||||||
# If any lookup fails, reject the subscription
|
# If any lookup fails, reject the subscription
|
||||||
self.log.warning(f"Subscription request from {sender_jid} rejected (recipient does not have this contact).")
|
self.log.warning(
|
||||||
|
f"Subscription request from {sender_jid} rejected (recipient does not have this contact)."
|
||||||
|
)
|
||||||
self.send_presence(ptype="unsubscribed", pto=sender_jid)
|
self.send_presence(ptype="unsubscribed", pto=sender_jid)
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def on_presence_subscribed(self, pres):
|
def on_presence_subscribed(self, pres):
|
||||||
"""
|
"""
|
||||||
Handle successful subscription confirmations.
|
Handle successful subscription confirmations.
|
||||||
@@ -325,16 +574,16 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
# self.log.error("No XEP-0363 upload service found.")
|
# self.log.error("No XEP-0363 upload service found.")
|
||||||
# return None
|
# return None
|
||||||
|
|
||||||
#self.log.info(f"Upload service: {upload_service}")
|
# self.log.info(f"Upload service: {upload_service}")
|
||||||
|
|
||||||
upload_service_jid = "share.zm.is"
|
upload_service_jid = "share.zm.is"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slot = await self['xep_0363'].request_slot(
|
slot = await self["xep_0363"].request_slot(
|
||||||
jid=upload_service_jid,
|
jid=upload_service_jid,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
size=size
|
size=size,
|
||||||
)
|
)
|
||||||
|
|
||||||
if slot is None:
|
if slot is None:
|
||||||
@@ -350,8 +599,12 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
put_url = put_element.attrib.get("url")
|
put_url = put_element.attrib.get("url")
|
||||||
|
|
||||||
# Extract the Authorization header correctly
|
# Extract the Authorization header correctly
|
||||||
header_element = put_element.find(f"./{namespace}header[@name='Authorization']")
|
header_element = put_element.find(
|
||||||
auth_header = header_element.text.strip() if header_element is not None else None
|
f"./{namespace}header[@name='Authorization']"
|
||||||
|
)
|
||||||
|
auth_header = (
|
||||||
|
header_element.text.strip() if header_element is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
if not get_url or not put_url:
|
if not get_url or not put_url:
|
||||||
self.log.error(f"Missing URLs in upload slot: {slot}")
|
self.log.error(f"Missing URLs in upload slot: {slot}")
|
||||||
@@ -363,7 +616,6 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.log.error(f"Exception while requesting upload slot: {e}")
|
self.log.error(f"Exception while requesting upload slot: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def message(self, msg):
|
async def message(self, msg):
|
||||||
"""
|
"""
|
||||||
Process incoming XMPP messages.
|
Process incoming XMPP messages.
|
||||||
@@ -374,13 +626,15 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
# Extract sender JID (full format: user@domain/resource)
|
# Extract sender JID (full format: user@domain/resource)
|
||||||
sender_jid = str(msg["from"])
|
sender_jid = str(msg["from"])
|
||||||
|
|
||||||
# Split into username@domain and optional resource
|
# Split into username@domain and optional resource
|
||||||
sender_parts = sender_jid.split("/", 1)
|
sender_parts = sender_jid.split("/", 1)
|
||||||
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
sender_bare_jid = sender_parts[0] # Always present: user@domain
|
||||||
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
sender_username, sender_domain = sender_bare_jid.split("@", 1)
|
||||||
|
|
||||||
sender_resource = sender_parts[1] if len(sender_parts) > 1 else None # Extract resource if present
|
sender_resource = (
|
||||||
|
sender_parts[1] if len(sender_parts) > 1 else None
|
||||||
|
) # Extract resource if present
|
||||||
|
|
||||||
# Extract recipient JID (should match component JID format)
|
# Extract recipient JID (should match component JID format)
|
||||||
recipient_jid = str(msg["to"])
|
recipient_jid = str(msg["to"])
|
||||||
@@ -399,19 +653,23 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
# Extract attachments from standard XMPP <attachments> (if present)
|
# Extract attachments from standard XMPP <attachments> (if present)
|
||||||
for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
|
||||||
attachments.append({
|
attachments.append(
|
||||||
"url": att.attrib.get("url"),
|
{
|
||||||
"filename": att.attrib.get("filename"),
|
"url": att.attrib.get("url"),
|
||||||
"content_type": att.attrib.get("content_type"),
|
"filename": att.attrib.get("filename"),
|
||||||
})
|
"content_type": att.attrib.get("content_type"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Extract attachments from XEP-0066 <x><url> format (Out of Band Data)
|
# Extract attachments from XEP-0066 <x><url> format (Out of Band Data)
|
||||||
for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
|
||||||
attachments.append({
|
attachments.append(
|
||||||
"url": oob.text,
|
{
|
||||||
"filename": oob.text.split("/")[-1], # Extract filename from URL
|
"url": oob.text,
|
||||||
"content_type": "application/octet-stream", # Generic content-type
|
"filename": oob.text.split("/")[-1], # Extract filename from URL
|
||||||
})
|
"content_type": "application/octet-stream", # Generic content-type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.log.info(f"Extracted {len(attachments)} attachments from XMPP message.")
|
self.log.info(f"Extracted {len(attachments)} attachments from XMPP message.")
|
||||||
# Log extracted information with variable name annotations
|
# Log extracted information with variable name annotations
|
||||||
@@ -426,7 +684,9 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
# Ensure recipient domain matches our configured component
|
# Ensure recipient domain matches our configured component
|
||||||
expected_domain = settings.XMPP_JID # 'jews.zm.is' in your config
|
expected_domain = settings.XMPP_JID # 'jews.zm.is' in your config
|
||||||
if recipient_domain != expected_domain:
|
if recipient_domain != expected_domain:
|
||||||
self.log.warning(f"Invalid recipient domain: {recipient_domain}, expected {expected_domain}")
|
self.log.warning(
|
||||||
|
f"Invalid recipient domain: {recipient_domain}, expected {expected_domain}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Lookup sender in Django's User model
|
# Lookup sender in Django's User model
|
||||||
@@ -452,6 +712,16 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
contact_names = [person.name for person in persons]
|
contact_names = [person.name for person in persons]
|
||||||
response_text = f"Contacts: " + ", ".join(contact_names)
|
response_text = f"Contacts: " + ", ".join(contact_names)
|
||||||
sym(response_text)
|
sym(response_text)
|
||||||
|
elif body == ".help":
|
||||||
|
sym("Commands: .contacts, .whoami, .mitigation help")
|
||||||
|
elif body.startswith(".mitigation"):
|
||||||
|
handled = await self._handle_mitigation_command(
|
||||||
|
sender_user,
|
||||||
|
body,
|
||||||
|
sym,
|
||||||
|
)
|
||||||
|
if not handled:
|
||||||
|
sym("Unknown mitigation command. Try .mitigation help")
|
||||||
elif body == ".whoami":
|
elif body == ".whoami":
|
||||||
sym(str(sender_user.__dict__))
|
sym(str(sender_user.__dict__))
|
||||||
else:
|
else:
|
||||||
@@ -468,7 +738,7 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
recipient_service = None
|
recipient_service = None
|
||||||
|
|
||||||
recipient_name = recipient_name.title()
|
recipient_name = recipient_name.title()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
person = Person.objects.get(user=sender_user, name=recipient_name)
|
person = Person.objects.get(user=sender_user, name=recipient_name)
|
||||||
except Person.DoesNotExist:
|
except Person.DoesNotExist:
|
||||||
@@ -476,21 +746,22 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
if recipient_service:
|
if recipient_service:
|
||||||
try:
|
try:
|
||||||
identifier = PersonIdentifier.objects.get(user=sender_user,
|
identifier = PersonIdentifier.objects.get(
|
||||||
person=person,
|
user=sender_user, person=person, service=recipient_service
|
||||||
service=recipient_service)
|
)
|
||||||
except PersonIdentifier.DoesNotExist:
|
except PersonIdentifier.DoesNotExist:
|
||||||
sym("This service identifier does not exist.")
|
sym("This service identifier does not exist.")
|
||||||
else:
|
else:
|
||||||
# Get a random identifier
|
# Get a random identifier
|
||||||
identifier = PersonIdentifier.objects.filter(user=sender_user,
|
identifier = PersonIdentifier.objects.filter(
|
||||||
person=person).first()
|
user=sender_user, person=person
|
||||||
|
).first()
|
||||||
recipient_service = identifier.service
|
recipient_service = identifier.service
|
||||||
|
|
||||||
# sym(str(person.__dict__))
|
# sym(str(person.__dict__))
|
||||||
# sym(f"Service: {recipient_service}")
|
# sym(f"Service: {recipient_service}")
|
||||||
|
|
||||||
#tss = await identifier.send(body, attachments=attachments)
|
# tss = await identifier.send(body, attachments=attachments)
|
||||||
# AM FIXING https://git.zm.is/XF/GIA/issues/5
|
# AM FIXING https://git.zm.is/XF/GIA/issues/5
|
||||||
session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
|
session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
|
||||||
identifier=identifier,
|
identifier=identifier,
|
||||||
@@ -502,7 +773,7 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
sender="XMPP",
|
sender="XMPP",
|
||||||
text=body,
|
text=body,
|
||||||
ts=int(now().timestamp() * 1000),
|
ts=int(now().timestamp() * 1000),
|
||||||
#outgoing=detail.is_outgoing_message, ????????? TODO:
|
# outgoing=detail.is_outgoing_message, ????????? TODO:
|
||||||
)
|
)
|
||||||
self.log.info("Stored a message sent from XMPP in the history.")
|
self.log.info("Stored a message sent from XMPP in the history.")
|
||||||
|
|
||||||
@@ -526,11 +797,11 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
chat_history = await history.get_chat_history(session)
|
chat_history = await history.get_chat_history(session)
|
||||||
await utils.update_last_interaction(session)
|
await utils.update_last_interaction(session)
|
||||||
prompt = replies.generate_mutate_reply_prompt(
|
prompt = replies.generate_mutate_reply_prompt(
|
||||||
body,
|
body,
|
||||||
identifier.person,
|
identifier.person,
|
||||||
manip,
|
manip,
|
||||||
chat_history,
|
chat_history,
|
||||||
)
|
)
|
||||||
self.log.info("Running XMPP context prompt")
|
self.log.info("Running XMPP context prompt")
|
||||||
result = await ai.run_prompt(prompt, manip.ai)
|
result = await ai.run_prompt(prompt, manip.ai)
|
||||||
self.log.info(f"RESULT {result}")
|
self.log.info(f"RESULT {result}")
|
||||||
@@ -546,17 +817,21 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
)
|
)
|
||||||
self.log.info(f"Message sent with modifications")
|
self.log.info(f"Message sent with modifications")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def request_upload_slots(self, recipient_jid, attachments):
|
async def request_upload_slots(self, recipient_jid, attachments):
|
||||||
"""Requests upload slots for multiple attachments concurrently."""
|
"""Requests upload slots for multiple attachments concurrently."""
|
||||||
upload_tasks = [
|
upload_tasks = [
|
||||||
self.request_upload_slot(recipient_jid, att["filename"], att["content_type"], att["size"])
|
self.request_upload_slot(
|
||||||
|
recipient_jid, att["filename"], att["content_type"], att["size"]
|
||||||
|
)
|
||||||
for att in attachments
|
for att in attachments
|
||||||
]
|
]
|
||||||
upload_slots = await asyncio.gather(*upload_tasks)
|
upload_slots = await asyncio.gather(*upload_tasks)
|
||||||
|
|
||||||
return [(att, slot) for att, slot in zip(attachments, upload_slots) if slot is not None]
|
return [
|
||||||
|
(att, slot)
|
||||||
|
for att, slot in zip(attachments, upload_slots)
|
||||||
|
if slot is not None
|
||||||
|
]
|
||||||
|
|
||||||
async def upload_and_send(self, att, upload_slot, recipient_jid, sender_jid):
|
async def upload_and_send(self, att, upload_slot, recipient_jid, sender_jid):
|
||||||
"""Uploads a file and immediately sends the corresponding XMPP message."""
|
"""Uploads a file and immediately sends the corresponding XMPP message."""
|
||||||
@@ -567,19 +842,29 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
try:
|
try:
|
||||||
async with session.put(put_url, data=att["content"], headers=headers) as response:
|
async with session.put(
|
||||||
|
put_url, data=att["content"], headers=headers
|
||||||
|
) as response:
|
||||||
if response.status not in (200, 201):
|
if response.status not in (200, 201):
|
||||||
self.log.error(f"Upload failed: {response.status} {await response.text()}")
|
self.log.error(
|
||||||
|
f"Upload failed: {response.status} {await response.text()}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
self.log.info(f"Successfully uploaded {att['filename']} to {upload_url}")
|
self.log.info(
|
||||||
|
f"Successfully uploaded {att['filename']} to {upload_url}"
|
||||||
|
)
|
||||||
|
|
||||||
# Send XMPP message immediately after successful upload
|
# Send XMPP message immediately after successful upload
|
||||||
await self.send_xmpp_message(recipient_jid, sender_jid, upload_url, attachment_url=upload_url)
|
await self.send_xmpp_message(
|
||||||
|
recipient_jid, sender_jid, upload_url, attachment_url=upload_url
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f"Error uploading {att['filename']} to XMPP: {e}")
|
self.log.error(f"Error uploading {att['filename']} to XMPP: {e}")
|
||||||
|
|
||||||
async def send_xmpp_message(self, recipient_jid, sender_jid, body_text, attachment_url=None):
|
async def send_xmpp_message(
|
||||||
|
self, recipient_jid, sender_jid, body_text, attachment_url=None
|
||||||
|
):
|
||||||
"""Sends an XMPP message with either text or an attachment URL."""
|
"""Sends an XMPP message with either text or an attachment URL."""
|
||||||
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
|
msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
|
||||||
msg["body"] = body_text # Body must contain only text or the URL
|
msg["body"] = body_text # Body must contain only text or the URL
|
||||||
@@ -594,7 +879,9 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.log.info(f"Sending XMPP message: {msg.xml}")
|
self.log.info(f"Sending XMPP message: {msg.xml}")
|
||||||
msg.send()
|
msg.send()
|
||||||
|
|
||||||
async def send_from_external(self, user, person_identifier, text, is_outgoing_message, attachments=[]):
|
async def send_from_external(
|
||||||
|
self, user, person_identifier, text, is_outgoing_message, attachments=[]
|
||||||
|
):
|
||||||
"""Handles sending XMPP messages with text and attachments."""
|
"""Handles sending XMPP messages with text and attachments."""
|
||||||
|
|
||||||
sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}"
|
sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}"
|
||||||
@@ -614,11 +901,12 @@ class XMPPComponent(ComponentXMPP):
|
|||||||
self.log.info(f"Got upload slots")
|
self.log.info(f"Got upload slots")
|
||||||
if not valid_uploads:
|
if not valid_uploads:
|
||||||
self.log.warning("No valid upload slots obtained.")
|
self.log.warning("No valid upload slots obtained.")
|
||||||
#return
|
# return
|
||||||
|
|
||||||
# Step 3: Upload each file and send its message immediately after upload
|
# Step 3: Upload each file and send its message immediately after upload
|
||||||
upload_tasks = [
|
upload_tasks = [
|
||||||
self.upload_and_send(att, slot, recipient_jid, sender_jid) for att, slot in valid_uploads
|
self.upload_and_send(att, slot, recipient_jid, sender_jid)
|
||||||
|
for att, slot in valid_uploads
|
||||||
]
|
]
|
||||||
await asyncio.gather(*upload_tasks) # Upload files concurrently
|
await asyncio.gather(*upload_tasks) # Upload files concurrently
|
||||||
|
|
||||||
@@ -634,12 +922,12 @@ class XMPPClient(ClientBase):
|
|||||||
port=settings.XMPP_PORT,
|
port=settings.XMPP_PORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client.register_plugin('xep_0030') # Service Discovery
|
self.client.register_plugin("xep_0030") # Service Discovery
|
||||||
self.client.register_plugin('xep_0004') # Data Forms
|
self.client.register_plugin("xep_0004") # Data Forms
|
||||||
self.client.register_plugin('xep_0060') # PubSub
|
self.client.register_plugin("xep_0060") # PubSub
|
||||||
self.client.register_plugin('xep_0199') # XMPP Ping
|
self.client.register_plugin("xep_0199") # XMPP Ping
|
||||||
self.client.register_plugin("xep_0085") # Chat State Notifications
|
self.client.register_plugin("xep_0085") # Chat State Notifications
|
||||||
self.client.register_plugin('xep_0363') # HTTP File Upload
|
self.client.register_plugin("xep_0363") # HTTP File Upload
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.log.info("XMPP client starting...")
|
self.log.info("XMPP client starting...")
|
||||||
@@ -648,4 +936,4 @@ class XMPPClient(ClientBase):
|
|||||||
self.client.loop = self.loop
|
self.client.loop = self.loop
|
||||||
|
|
||||||
self.client.connect()
|
self.client.connect()
|
||||||
#self.client.process()
|
# self.client.process()
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
import aiomysql
|
import aiomysql
|
||||||
|
|
||||||
from core.util import logs
|
|
||||||
from core.schemas import mc_s
|
from core.schemas import mc_s
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
mysql_pool = None
|
mysql_pool = None
|
||||||
|
|
||||||
log = logs.get_logger("sql")
|
log = logs.get_logger("sql")
|
||||||
|
|
||||||
DB_URL = "giadb"
|
DB_URL = "giadb"
|
||||||
|
|
||||||
|
|
||||||
async def init_mysql_pool():
|
async def init_mysql_pool():
|
||||||
"""
|
"""
|
||||||
Initialize the MySQL connection pool.
|
Initialize the MySQL connection pool.
|
||||||
"""
|
"""
|
||||||
global mysql_pool
|
global mysql_pool
|
||||||
mysql_pool = await aiomysql.create_pool(
|
mysql_pool = await aiomysql.create_pool(
|
||||||
host=DB_URL,
|
host=DB_URL, port=9306, db="Manticore", minsize=1, maxsize=10
|
||||||
port=9306,
|
|
||||||
db="Manticore",
|
|
||||||
minsize=1,
|
|
||||||
maxsize=10
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def close_mysql_pool():
|
async def close_mysql_pool():
|
||||||
"""Close the MySQL connection pool properly."""
|
"""Close the MySQL connection pool properly."""
|
||||||
global mysql_pool
|
global mysql_pool
|
||||||
@@ -42,11 +41,9 @@ async def create_index():
|
|||||||
for name, schema in schemas.items():
|
for name, schema in schemas.items():
|
||||||
schema_types = ", ".join([f"{k} {v}" for k, v in schema.items()])
|
schema_types = ", ".join([f"{k} {v}" for k, v in schema.items()])
|
||||||
|
|
||||||
create_query = (
|
create_query = f"create table if not exists {name}({schema_types}) engine='columnar'"
|
||||||
f"create table if not exists {name}({schema_types}) engine='columnar'"
|
|
||||||
)
|
|
||||||
log.info(f"Schema types {create_query}")
|
log.info(f"Schema types {create_query}")
|
||||||
await cur.execute(create_query) # SQLi
|
await cur.execute(create_query) # SQLi
|
||||||
except aiomysql.Error as e:
|
except aiomysql.Error as e:
|
||||||
log.error(f"MySQL error: {e}")
|
log.error(f"MySQL error: {e}")
|
||||||
|
|
||||||
@@ -60,4 +57,4 @@ async def main():
|
|||||||
created = True
|
created = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error creating index: {e}")
|
log.error(f"Error creating index: {e}")
|
||||||
await asyncio.sleep(1) # Block the thread, just wait for the DB
|
await asyncio.sleep(1) # Block the thread, just wait for the DB
|
||||||
|
|||||||
@@ -3,7 +3,19 @@ from django.contrib.auth.forms import UserCreationForm
|
|||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from mixins.restrictions import RestrictedFormMixin
|
from mixins.restrictions import RestrictedFormMixin
|
||||||
|
|
||||||
from .models import NotificationSettings, User, AI, PersonIdentifier, Person, Group, Persona, Manipulation, ChatSession, Message, QueuedMessage
|
from .models import (
|
||||||
|
AI,
|
||||||
|
ChatSession,
|
||||||
|
Group,
|
||||||
|
Manipulation,
|
||||||
|
Message,
|
||||||
|
NotificationSettings,
|
||||||
|
Person,
|
||||||
|
Persona,
|
||||||
|
PersonIdentifier,
|
||||||
|
QueuedMessage,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
# Create your forms here.
|
# Create your forms here.
|
||||||
|
|
||||||
@@ -48,6 +60,7 @@ class CustomUserCreationForm(UserCreationForm):
|
|||||||
model = User
|
model = User
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class AIForm(RestrictedFormMixin, forms.ModelForm):
|
class AIForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AI
|
model = AI
|
||||||
@@ -61,6 +74,7 @@ class AIForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"model": "Select the AI model to be used.",
|
"model": "Select the AI model to be used.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PersonIdentifierForm(RestrictedFormMixin, forms.ModelForm):
|
class PersonIdentifierForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PersonIdentifier
|
model = PersonIdentifier
|
||||||
@@ -70,10 +84,21 @@ class PersonIdentifierForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"service": "The platform associated with this identifier (e.g., Signal, Instagram).",
|
"service": "The platform associated with this identifier (e.g., Signal, Instagram).",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PersonForm(RestrictedFormMixin, forms.ModelForm):
|
class PersonForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Person
|
model = Person
|
||||||
fields = ("name", "summary", "profile", "revealed", "dislikes", "likes", "sentiment", "timezone", "last_interaction")
|
fields = (
|
||||||
|
"name",
|
||||||
|
"summary",
|
||||||
|
"profile",
|
||||||
|
"revealed",
|
||||||
|
"dislikes",
|
||||||
|
"likes",
|
||||||
|
"sentiment",
|
||||||
|
"timezone",
|
||||||
|
"last_interaction",
|
||||||
|
)
|
||||||
help_texts = {
|
help_texts = {
|
||||||
"name": "The full name of the person.",
|
"name": "The full name of the person.",
|
||||||
"summary": "A brief summary or description of this person.",
|
"summary": "A brief summary or description of this person.",
|
||||||
@@ -86,6 +111,7 @@ class PersonForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"last_interaction": "The date and time of the last recorded interaction.",
|
"last_interaction": "The date and time of the last recorded interaction.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class GroupForm(RestrictedFormMixin, forms.ModelForm):
|
class GroupForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Group
|
model = Group
|
||||||
@@ -94,6 +120,7 @@ class GroupForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"name": "The name of the group.",
|
"name": "The name of the group.",
|
||||||
"people": "People who are part of this group.",
|
"people": "People who are part of this group.",
|
||||||
}
|
}
|
||||||
|
|
||||||
people = forms.ModelMultipleChoiceField(
|
people = forms.ModelMultipleChoiceField(
|
||||||
queryset=Person.objects.all(),
|
queryset=Person.objects.all(),
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
@@ -101,13 +128,27 @@ class GroupForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PersonaForm(RestrictedFormMixin, forms.ModelForm):
|
class PersonaForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Persona
|
model = Persona
|
||||||
fields = (
|
fields = (
|
||||||
"alias", "mbti", "mbti_identity", "inner_story", "core_values", "communication_style",
|
"alias",
|
||||||
"flirting_style", "humor_style", "likes", "dislikes", "tone",
|
"mbti",
|
||||||
"response_tactics", "persuasion_tactics", "boundaries", "trust", "adaptability"
|
"mbti_identity",
|
||||||
|
"inner_story",
|
||||||
|
"core_values",
|
||||||
|
"communication_style",
|
||||||
|
"flirting_style",
|
||||||
|
"humor_style",
|
||||||
|
"likes",
|
||||||
|
"dislikes",
|
||||||
|
"tone",
|
||||||
|
"response_tactics",
|
||||||
|
"persuasion_tactics",
|
||||||
|
"boundaries",
|
||||||
|
"trust",
|
||||||
|
"adaptability",
|
||||||
)
|
)
|
||||||
help_texts = {
|
help_texts = {
|
||||||
"alias": "The preferred name or identity for this persona.",
|
"alias": "The preferred name or identity for this persona.",
|
||||||
@@ -128,6 +169,7 @@ class PersonaForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"adaptability": "How easily this persona shifts tones or styles (0-100).",
|
"adaptability": "How easily this persona shifts tones or styles (0-100).",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ManipulationForm(RestrictedFormMixin, forms.ModelForm):
|
class ManipulationForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Manipulation
|
model = Manipulation
|
||||||
@@ -135,7 +177,7 @@ class ManipulationForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
help_texts = {
|
help_texts = {
|
||||||
"name": "The name of this manipulation strategy.",
|
"name": "The name of this manipulation strategy.",
|
||||||
"group": "The group involved in this manipulation strategy.",
|
"group": "The group involved in this manipulation strategy.",
|
||||||
#"self": "Group for own UUIDs.",
|
# "self": "Group for own UUIDs.",
|
||||||
"ai": "The AI associated with this manipulation.",
|
"ai": "The AI associated with this manipulation.",
|
||||||
"persona": "The persona used for this manipulation.",
|
"persona": "The persona used for this manipulation.",
|
||||||
"enabled": "Whether this manipulation is enabled.",
|
"enabled": "Whether this manipulation is enabled.",
|
||||||
@@ -153,6 +195,7 @@ class SessionForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"summary": "Summary of chat transcript.",
|
"summary": "Summary of chat transcript.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MessageForm(RestrictedFormMixin, forms.ModelForm):
|
class MessageForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Message
|
model = Message
|
||||||
@@ -164,6 +207,7 @@ class MessageForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"custom_author": "For detecting USER and BOT messages.",
|
"custom_author": "For detecting USER and BOT messages.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class QueueForm(RestrictedFormMixin, forms.ModelForm):
|
class QueueForm(RestrictedFormMixin, forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QueuedMessage
|
model = QueuedMessage
|
||||||
@@ -172,4 +216,20 @@ class QueueForm(RestrictedFormMixin, forms.ModelForm):
|
|||||||
"session": "Chat session this message will be sent in.",
|
"session": "Chat session this message will be sent in.",
|
||||||
"manipulation": "Manipulation that generated the message.",
|
"manipulation": "Manipulation that generated the message.",
|
||||||
"text": "Content of the proposed message.",
|
"text": "Content of the proposed message.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AIWorkspaceWindowForm(forms.Form):
|
||||||
|
"""Controls the message window size for AI workspace previews."""
|
||||||
|
|
||||||
|
limit = forms.ChoiceField(
|
||||||
|
choices=(
|
||||||
|
("20", "Last 20"),
|
||||||
|
("50", "Last 50"),
|
||||||
|
("100", "Last 100"),
|
||||||
|
),
|
||||||
|
initial="20",
|
||||||
|
required=True,
|
||||||
|
help_text="How many most-recent messages to load for the selected person.",
|
||||||
|
widget=forms.Select(attrs={"class": "is-fullwidth"}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
# Deferred processing library
|
# Deferred processing library
|
||||||
from core.util import logs
|
import asyncio
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import ValidationError
|
|
||||||
from core.models import QueuedMessage, Message, PersonIdentifier, User
|
|
||||||
from core.clients import signal
|
|
||||||
from core.lib.prompts.functions import delete_messages
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from core.clients import signalapi
|
from pydantic import BaseModel, ValidationError
|
||||||
import asyncio
|
|
||||||
|
|
||||||
|
|
||||||
|
from core.clients import signal, signalapi
|
||||||
|
from core.lib.prompts.functions import delete_messages
|
||||||
|
from core.models import Message, PersonIdentifier, QueuedMessage, User
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("deferred")
|
log = logs.get_logger("deferred")
|
||||||
|
|
||||||
|
|
||||||
class DeferredDetail(BaseModel):
|
class DeferredDetail(BaseModel):
|
||||||
reply_to_self: bool
|
reply_to_self: bool
|
||||||
reply_to_others: bool
|
reply_to_others: bool
|
||||||
is_outgoing_message: bool
|
is_outgoing_message: bool
|
||||||
|
|
||||||
|
|
||||||
class DeferredRequest(BaseModel):
|
class DeferredRequest(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
method: str
|
method: str
|
||||||
@@ -32,33 +32,35 @@ class DeferredRequest(BaseModel):
|
|||||||
detail: Optional[DeferredDetail] = None
|
detail: Optional[DeferredDetail] = None
|
||||||
attachments: Optional[list] = None
|
attachments: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
async def send_message(db_obj):
|
async def send_message(db_obj):
|
||||||
recipient_uuid = db_obj.session.identifier.identifier
|
recipient_uuid = db_obj.session.identifier.identifier
|
||||||
text = db_obj.text
|
text = db_obj.text
|
||||||
|
|
||||||
send = lambda x: signalapi.send_message_raw(recipient_uuid, x) # returns ts
|
send = lambda x: signalapi.send_message_raw(recipient_uuid, x) # returns ts
|
||||||
start_t = lambda: signalapi.start_typing(recipient_uuid)
|
start_t = lambda: signalapi.start_typing(recipient_uuid)
|
||||||
stop_t = lambda: signalapi.stop_typing(recipient_uuid)
|
stop_t = lambda: signalapi.stop_typing(recipient_uuid)
|
||||||
|
|
||||||
|
tss = await natural.natural_send_message(
|
||||||
|
text,
|
||||||
|
send,
|
||||||
|
start_t,
|
||||||
|
stop_t,
|
||||||
|
) # list of ts
|
||||||
|
# result = await send_message_raw(recipient_uuid, text)
|
||||||
|
await sync_to_async(db_obj.delete)()
|
||||||
|
result = [x for x in tss if x] # all trueish ts
|
||||||
|
if result: # if at least one message was sent
|
||||||
|
ts1 = result.pop() # pick a time
|
||||||
|
log.info(f"signal message create {text}")
|
||||||
|
await sync_to_async(Message.objects.create)(
|
||||||
|
user=db_obj.session.user,
|
||||||
|
session=db_obj.session,
|
||||||
|
custom_author="BOT",
|
||||||
|
text=text,
|
||||||
|
ts=ts1, # use that time in db
|
||||||
|
)
|
||||||
|
|
||||||
tss = await natural.natural_send_message(
|
|
||||||
text,
|
|
||||||
send,
|
|
||||||
start_t,
|
|
||||||
stop_t,
|
|
||||||
) # list of ts
|
|
||||||
#result = await send_message_raw(recipient_uuid, text)
|
|
||||||
await sync_to_async(db_obj.delete)()
|
|
||||||
result = [x for x in tss if x] # all trueish ts
|
|
||||||
if result: # if at least one message was sent
|
|
||||||
ts1 = result.pop() # pick a time
|
|
||||||
log.info(f"signal message create {text}")
|
|
||||||
await sync_to_async(Message.objects.create)(
|
|
||||||
user=db_obj.session.user,
|
|
||||||
session=db_obj.session,
|
|
||||||
custom_author="BOT",
|
|
||||||
text=text,
|
|
||||||
ts=ts1, # use that time in db
|
|
||||||
)
|
|
||||||
|
|
||||||
async def process_deferred(data: dict, **kwargs):
|
async def process_deferred(data: dict, **kwargs):
|
||||||
try:
|
try:
|
||||||
@@ -68,12 +70,11 @@ async def process_deferred(data: dict, **kwargs):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
log.info(f"Validation Error: {e}")
|
log.info(f"Validation Error: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
method = validated_data.method
|
method = validated_data.method
|
||||||
user_id = validated_data.user_id
|
user_id = validated_data.user_id
|
||||||
message_id = validated_data.message_id
|
message_id = validated_data.message_id
|
||||||
|
|
||||||
|
|
||||||
if method == "accept_message":
|
if method == "accept_message":
|
||||||
try:
|
try:
|
||||||
message = await sync_to_async(QueuedMessage.objects.get)(
|
message = await sync_to_async(QueuedMessage.objects.get)(
|
||||||
@@ -91,7 +92,7 @@ async def process_deferred(data: dict, **kwargs):
|
|||||||
else:
|
else:
|
||||||
log.warning(f"Protocol not supported: {message.session.identifier.service}")
|
log.warning(f"Protocol not supported: {message.session.identifier.service}")
|
||||||
return
|
return
|
||||||
elif method == "xmpp": # send xmpp message
|
elif method == "xmpp": # send xmpp message
|
||||||
xmpp = kwargs.get("xmpp")
|
xmpp = kwargs.get("xmpp")
|
||||||
service = validated_data.service
|
service = validated_data.service
|
||||||
msg = validated_data.msg
|
msg = validated_data.msg
|
||||||
@@ -115,18 +116,28 @@ async def process_deferred(data: dict, **kwargs):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Attach fetched file to XMPP
|
# Attach fetched file to XMPP
|
||||||
xmpp_attachments.append({
|
xmpp_attachments.append(
|
||||||
"content": fetched["content"],
|
{
|
||||||
"content_type": fetched["content_type"],
|
"content": fetched["content"],
|
||||||
"filename": fetched["filename"],
|
"content_type": fetched["content_type"],
|
||||||
"size": fetched["size"],
|
"filename": fetched["filename"],
|
||||||
})
|
"size": fetched["size"],
|
||||||
|
}
|
||||||
|
)
|
||||||
for identifier in identifiers:
|
for identifier in identifiers:
|
||||||
#recipient_jid = f"{identifier.user.username}@{settings.XMPP_ADDRESS}"
|
# recipient_jid = f"{identifier.user.username}@{settings.XMPP_ADDRESS}"
|
||||||
user = identifier.user
|
user = identifier.user
|
||||||
|
|
||||||
log.info(f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP.")
|
log.info(
|
||||||
await xmpp.send_from_external(user, identifier, msg, validated_data.detail, attachments=xmpp_attachments)
|
f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
|
||||||
|
)
|
||||||
|
await xmpp.send_from_external(
|
||||||
|
user,
|
||||||
|
identifier,
|
||||||
|
msg,
|
||||||
|
validated_data.detail,
|
||||||
|
attachments=xmpp_attachments,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
log.warning(f"Method not yet supported: {method}")
|
log.warning(f"Method not yet supported: {method}")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -339,4 +339,4 @@ To make comments about being messaged late, keep in mind THEIR time zone.
|
|||||||
Contact: hi (their time zone is latvia and my current time is 22:30)
|
Contact: hi (their time zone is latvia and my current time is 22:30)
|
||||||
Me: hi, it’s late there. what’s up?
|
Me: hi, it’s late there. what’s up?
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,147 +1,12 @@
|
|||||||
from core.lib.prompts import bases
|
"""
|
||||||
from openai import AsyncOpenAI
|
Prompt utility helpers.
|
||||||
|
|
||||||
|
Legacy summarization-based history compaction was intentionally removed.
|
||||||
|
History is now preserved in storage and bounded only at prompt-build time.
|
||||||
|
"""
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from core.models import Message, ChatSession, AI, Person, Manipulation
|
|
||||||
from core.util import logs
|
|
||||||
import json
|
|
||||||
from django.utils import timezone
|
|
||||||
from core.messaging import ai
|
|
||||||
from core.messaging.utils import messages_to_string
|
|
||||||
|
|
||||||
SUMMARIZE_WHEN_EXCEEDING = 10
|
|
||||||
SUMMARIZE_BY = 5
|
|
||||||
|
|
||||||
MAX_SUMMARIES = 3 # Keep last 5 summaries
|
|
||||||
|
|
||||||
log = logs.get_logger("prompts")
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_messages(queryset):
|
async def delete_messages(queryset):
|
||||||
await sync_to_async(queryset.delete, thread_sensitive=True)()
|
await sync_to_async(queryset.delete, thread_sensitive=True)()
|
||||||
|
|
||||||
async def truncate_and_summarize(
|
|
||||||
chat_session: ChatSession,
|
|
||||||
ai_obj: AI,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Summarizes messages in chunks to prevent unchecked growth.
|
|
||||||
- Summarizes only non-summary messages.
|
|
||||||
- Deletes older summaries if too many exist.
|
|
||||||
- Ensures only messages belonging to `chat_session.user` are modified.
|
|
||||||
"""
|
|
||||||
user = chat_session.user # Store the user for ownership checks
|
|
||||||
|
|
||||||
# 🔹 Get non-summary messages owned by the session's user
|
|
||||||
messages = await sync_to_async(list)(
|
|
||||||
Message.objects.filter(session=chat_session, user=user)
|
|
||||||
.exclude(custom_author="SUM")
|
|
||||||
.order_by("ts")
|
|
||||||
)
|
|
||||||
|
|
||||||
num_messages = len(messages)
|
|
||||||
|
|
||||||
if num_messages >= SUMMARIZE_WHEN_EXCEEDING:
|
|
||||||
log.info(f"Summarizing {SUMMARIZE_BY} messages for session {chat_session.id}")
|
|
||||||
|
|
||||||
# Get the first `SUMMARIZE_BY` non-summary messages
|
|
||||||
chunk_to_summarize = messages[:SUMMARIZE_BY]
|
|
||||||
|
|
||||||
if not chunk_to_summarize:
|
|
||||||
log.warning("No messages available to summarize (only summaries exist). Skipping summarization.")
|
|
||||||
return
|
|
||||||
|
|
||||||
last_ts = chunk_to_summarize[-1].ts # Preserve timestamp
|
|
||||||
|
|
||||||
# 🔹 Get past summaries, keeping only the last few (owned by the session user)
|
|
||||||
summary_messages = await sync_to_async(list)(
|
|
||||||
Message.objects.filter(session=chat_session, user=user, custom_author="SUM")
|
|
||||||
.order_by("ts")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete old summaries if there are too many
|
|
||||||
if len(summary_messages) >= MAX_SUMMARIES:
|
|
||||||
summary_text = await summarize_conversation(chat_session, summary_messages, ai_obj, is_summary=True)
|
|
||||||
|
|
||||||
chat_session.summary = summary_text
|
|
||||||
await sync_to_async(chat_session.save)()
|
|
||||||
log.info(f"Updated ChatSession summary with {len(summary_messages)} consolidated summaries.")
|
|
||||||
|
|
||||||
num_to_delete = len(summary_messages) - MAX_SUMMARIES
|
|
||||||
# await sync_to_async(
|
|
||||||
# Message.objects.filter(session=chat_session, user=user, id__in=[msg.id for msg in summary_messages[:num_to_delete]])
|
|
||||||
# .delete()
|
|
||||||
# )()
|
|
||||||
await delete_messages(
|
|
||||||
Message.objects.filter(
|
|
||||||
session=chat_session,
|
|
||||||
user=user,
|
|
||||||
id__in=[msg.id for msg in summary_messages[:num_to_delete]]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
log.info(f"Deleted {num_to_delete} old summaries.")
|
|
||||||
|
|
||||||
# 🔹 Summarize conversation chunk
|
|
||||||
summary_text = await summarize_conversation(chat_session, chunk_to_summarize, ai_obj)
|
|
||||||
|
|
||||||
# 🔹 Replace old messages with the summary
|
|
||||||
# await sync_to_async(
|
|
||||||
# Message.objects.filter(session=chat_session, user=user, id__in=[msg.id for msg in chunk_to_summarize])
|
|
||||||
# .delete()
|
|
||||||
# )()
|
|
||||||
await delete_messages(Message.objects.filter(session=chat_session, user=user, id__in=[msg.id for msg in chunk_to_summarize]))
|
|
||||||
log.info(f"Deleted {len(chunk_to_summarize)} messages, replacing with summary.")
|
|
||||||
|
|
||||||
# 🔹 Store new summary message (ensuring session=user consistency)
|
|
||||||
await sync_to_async(Message.objects.create)(
|
|
||||||
user=user,
|
|
||||||
session=chat_session,
|
|
||||||
custom_author="SUM",
|
|
||||||
text=summary_text,
|
|
||||||
ts=last_ts, # Preserve timestamp
|
|
||||||
)
|
|
||||||
|
|
||||||
# 🔹 Update ChatSession summary with latest merged summary
|
|
||||||
# chat_session.summary = summary_text
|
|
||||||
# await sync_to_async(chat_session.save)()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def summarize_conversation(
|
|
||||||
chat_session: ChatSession,
|
|
||||||
messages: list[Message],
|
|
||||||
ai_obj,
|
|
||||||
is_summary=False,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Summarizes all stored messages into a single summary.
|
|
||||||
|
|
||||||
- If `is_summary=True`, treats input as previous summaries and merges them while keeping detail.
|
|
||||||
- If `is_summary=False`, summarizes raw chat messages concisely.
|
|
||||||
"""
|
|
||||||
|
|
||||||
log.info(f"Summarizing messages for session {chat_session.id}")
|
|
||||||
|
|
||||||
# Convert messages to structured text format
|
|
||||||
message_texts = messages_to_string(messages)
|
|
||||||
#log.info(f"Raw messages to summarize:\n{message_texts}")
|
|
||||||
|
|
||||||
# Select appropriate summarization instruction
|
|
||||||
instruction = (
|
|
||||||
"Merge and refine these past summaries, keeping critical details and structure intact."
|
|
||||||
if is_summary
|
|
||||||
else "Summarize this conversation concisely, maintaining important details and tone."
|
|
||||||
)
|
|
||||||
|
|
||||||
summary_prompt = [
|
|
||||||
{"role": "system", "content": instruction},
|
|
||||||
{"role": "user", "content": f"Conversation:\n{message_texts}\n\nProvide a clear and structured summary:"},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Generate AI-based summary
|
|
||||||
summary_text = await ai.run_prompt(summary_prompt, ai_obj)
|
|
||||||
#log.info(f"Generated Summary: {summary_text}")
|
|
||||||
|
|
||||||
return f"Summary: {summary_text}"
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ async def job(interval_seconds):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from core.util import logs
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.conf import settings
|
|
||||||
from core.modules.router import UnifiedRouter
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from core.modules.router import UnifiedRouter
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("UR")
|
log = logs.get_logger("UR")
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
@@ -13,6 +16,6 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
instance = UnifiedRouter(loop)
|
instance = UnifiedRouter(loop)
|
||||||
|
|
||||||
#instance.start()
|
# instance.start()
|
||||||
|
|
||||||
instance.run()
|
instance.run()
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from openai import AsyncOpenAI, OpenAI
|
from openai import AsyncOpenAI, OpenAI
|
||||||
from core.models import Message, ChatSession, AI, Person, Manipulation
|
|
||||||
|
from core.models import AI, ChatSession, Manipulation, Message, Person
|
||||||
|
|
||||||
|
|
||||||
async def run_prompt(
|
async def run_prompt(
|
||||||
prompt: list[str],
|
prompt: list[str],
|
||||||
ai: AI,
|
ai: AI,
|
||||||
):
|
):
|
||||||
cast = {"api_key": ai.api_key}
|
cast = {"api_key": ai.api_key}
|
||||||
if ai.base_url is not None:
|
if ai.base_url is not None:
|
||||||
cast["api_key"] = ai.base_url
|
cast["base_url"] = ai.base_url
|
||||||
client = AsyncOpenAI(**cast)
|
client = AsyncOpenAI(**cast)
|
||||||
response = await client.chat.completions.create(
|
response = await client.chat.completions.create(
|
||||||
model=ai.model,
|
model=ai.model,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from core.lib.prompts import bases
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from core.models import Message, ChatSession, AI, Person, Manipulation
|
|
||||||
from core.util import logs
|
|
||||||
import json
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from django.utils import timezone
|
import json
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.utils import timezone
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from core.lib.prompts import bases
|
||||||
|
from core.models import AI, ChatSession, Manipulation, Message, Person
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
|
|
||||||
def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history: str):
|
def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history: str):
|
||||||
"""
|
"""
|
||||||
Generate a structured prompt using the attributes of the provided Person and Manipulation models.
|
Generate a structured prompt using the attributes of the provided Person and Manipulation models.
|
||||||
@@ -18,7 +21,6 @@ def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history
|
|||||||
|
|
||||||
system_message = (
|
system_message = (
|
||||||
"You are my digital persona, responding on my behalf while embodying my personality, preferences, and unique style.\n\n"
|
"You are my digital persona, responding on my behalf while embodying my personality, preferences, and unique style.\n\n"
|
||||||
|
|
||||||
"### Persona Profile ###\n"
|
"### Persona Profile ###\n"
|
||||||
f"- **MBTI:** {persona.mbti} ({persona.mbti_identity} balance)\n"
|
f"- **MBTI:** {persona.mbti} ({persona.mbti_identity} balance)\n"
|
||||||
f"- **Tone:** {persona.tone} | **Humor:** {persona.humor_style}\n"
|
f"- **Tone:** {persona.tone} | **Humor:** {persona.humor_style}\n"
|
||||||
@@ -29,7 +31,6 @@ def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history
|
|||||||
f"- **Response Tactics:** {persona.response_tactics}\n"
|
f"- **Response Tactics:** {persona.response_tactics}\n"
|
||||||
f"- **Persuasion Techniques:** {persona.persuasion_tactics}\n"
|
f"- **Persuasion Techniques:** {persona.persuasion_tactics}\n"
|
||||||
f"- **Boundaries:** {persona.boundaries} | **Adaptability:** {persona.adaptability}%\n\n"
|
f"- **Boundaries:** {persona.boundaries} | **Adaptability:** {persona.adaptability}%\n\n"
|
||||||
|
|
||||||
"### Contact Information ###\n"
|
"### Contact Information ###\n"
|
||||||
f"- **Summary:** {person.summary or 'N/A'}\n"
|
f"- **Summary:** {person.summary or 'N/A'}\n"
|
||||||
f"- **Profile:** {person.profile or 'N/A'}\n"
|
f"- **Profile:** {person.profile or 'N/A'}\n"
|
||||||
@@ -38,10 +39,8 @@ def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history
|
|||||||
f"- **Timezone:** {person.timezone or 'N/A'}\n"
|
f"- **Timezone:** {person.timezone or 'N/A'}\n"
|
||||||
f"- **Last Interaction:** {person.last_interaction or 'Never'}\n"
|
f"- **Last Interaction:** {person.last_interaction or 'Never'}\n"
|
||||||
f"- **Current Date/Time:** {now}\n\n"
|
f"- **Current Date/Time:** {now}\n\n"
|
||||||
|
|
||||||
"### Conversation Context ###\n"
|
"### Conversation Context ###\n"
|
||||||
f"{chat_history if chat_history else 'No prior chat history.'}\n\n"
|
f"{chat_history if chat_history else 'No prior chat history.'}\n\n"
|
||||||
|
|
||||||
"### Response Guidelines ###\n"
|
"### Response Guidelines ###\n"
|
||||||
"- **Engagement**: Keep responses engaging, with a balance of wit, depth, and confidence.\n"
|
"- **Engagement**: Keep responses engaging, with a balance of wit, depth, and confidence.\n"
|
||||||
"- **Flirting**: Be direct, playful, and, when appropriate, subtly provocative—without hesitation.\n"
|
"- **Flirting**: Be direct, playful, and, when appropriate, subtly provocative—without hesitation.\n"
|
||||||
@@ -56,10 +55,11 @@ def generate_prompt(msg: dict, person: Person, manip: Manipulation, chat_history
|
|||||||
{"role": "user", "content": user_message},
|
{"role": "user", "content": user_message},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def run_context_prompt(
|
async def run_context_prompt(
|
||||||
prompt: list[str],
|
prompt: list[str],
|
||||||
ai: AI,
|
ai: AI,
|
||||||
):
|
):
|
||||||
cast = {"api_key": ai.api_key}
|
cast = {"api_key": ai.api_key}
|
||||||
if ai.base_url is not None:
|
if ai.base_url is not None:
|
||||||
cast["api_key"] = ai.base_url
|
cast["api_key"] = ai.base_url
|
||||||
@@ -70,4 +70,4 @@ async def run_context_prompt(
|
|||||||
)
|
)
|
||||||
content = response.choices[0].message.content
|
content = response.choices[0].message.content
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|||||||
@@ -1,19 +1,136 @@
|
|||||||
from core.util import logs
|
|
||||||
from core.models import Message, ChatSession, QueuedMessage
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
|
from core.models import ChatSession, Message, QueuedMessage
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("history")
|
log = logs.get_logger("history")
|
||||||
|
|
||||||
async def get_chat_history(session):
|
# Prompt-window controls:
|
||||||
stored_messages = await sync_to_async(list)(
|
# - Full message history is always persisted in the database.
|
||||||
Message.objects.filter(session=session, user=session.user).order_by("ts")
|
# - Only the prompt input window is reduced.
|
||||||
|
# - Max values are hard safety rails; runtime chooses a smaller adaptive subset.
|
||||||
|
# - Min value prevents overly aggressive clipping on very long average messages.
|
||||||
|
DEFAULT_PROMPT_HISTORY_MAX_MESSAGES = getattr(
|
||||||
|
settings, "PROMPT_HISTORY_MAX_MESSAGES", 120
|
||||||
|
)
|
||||||
|
DEFAULT_PROMPT_HISTORY_MAX_CHARS = getattr(settings, "PROMPT_HISTORY_MAX_CHARS", 24000)
|
||||||
|
DEFAULT_PROMPT_HISTORY_MIN_MESSAGES = getattr(
|
||||||
|
settings, "PROMPT_HISTORY_MIN_MESSAGES", 24
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_recent_history(messages, max_chars):
|
||||||
|
"""
|
||||||
|
Build the final prompt transcript under a strict character budget.
|
||||||
|
|
||||||
|
Method:
|
||||||
|
1. Iterate messages from newest to oldest so recency is prioritized.
|
||||||
|
2. For each message, estimate the rendered line length exactly as it will
|
||||||
|
appear in the prompt transcript.
|
||||||
|
3. Stop once adding another line would exceed `max_chars`, while still
|
||||||
|
guaranteeing at least one message can be included.
|
||||||
|
4. Reverse back to chronological order for readability in prompts.
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
selected = []
|
||||||
|
total_chars = 0
|
||||||
|
# Recency-first packing, then reorder to chronological output later.
|
||||||
|
for msg in reversed(messages):
|
||||||
|
line = f"[{msg.ts}] <{msg.custom_author if msg.custom_author else msg.session.identifier.person.name}> {msg.text}"
|
||||||
|
line_len = len(line) + 1
|
||||||
|
# Keep at least one line even if it alone exceeds max_chars.
|
||||||
|
if selected and (total_chars + line_len) > max_chars:
|
||||||
|
break
|
||||||
|
selected.append(msg)
|
||||||
|
total_chars += line_len
|
||||||
|
|
||||||
|
selected.reverse()
|
||||||
|
return messages_to_string(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_adaptive_message_limit(messages, max_messages, max_chars):
|
||||||
|
"""
|
||||||
|
Derive how many messages to include before final char-budget packing.
|
||||||
|
|
||||||
|
This function intentionally avoids hand-picked threshold buckets.
|
||||||
|
Instead, it computes a budget-derived estimate:
|
||||||
|
- Build a recent sample (up to 80 messages) representing current chat style.
|
||||||
|
- Measure *rendered* line lengths (timestamp + author + text), not raw text.
|
||||||
|
- Estimate average line length from that sample.
|
||||||
|
- Convert char budget into message budget: floor(max_chars / avg_line_len).
|
||||||
|
- Clamp to configured min/max rails.
|
||||||
|
|
||||||
|
Why two stages:
|
||||||
|
- Stage A (this function): estimate count from current message density.
|
||||||
|
- Stage B (`_build_recent_history`): enforce exact char ceiling.
|
||||||
|
This keeps behavior stable while guaranteeing hard prompt budget compliance.
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return DEFAULT_PROMPT_HISTORY_MIN_MESSAGES
|
||||||
|
|
||||||
|
sample = messages[-min(len(messages), 80) :]
|
||||||
|
rendered_lengths = []
|
||||||
|
for msg in sample:
|
||||||
|
author = (
|
||||||
|
msg.custom_author
|
||||||
|
if msg.custom_author
|
||||||
|
else msg.session.identifier.person.name
|
||||||
|
)
|
||||||
|
text = msg.text or ""
|
||||||
|
# Match the line shape used in _build_recent_history/messages_to_string.
|
||||||
|
rendered_lengths.append(len(f"[{msg.ts}] <{author}> {text}") + 1)
|
||||||
|
|
||||||
|
# Defensive denominator: never divide by zero.
|
||||||
|
avg_line_len = (
|
||||||
|
(sum(rendered_lengths) / len(rendered_lengths)) if rendered_lengths else 1.0
|
||||||
)
|
)
|
||||||
recent_chat_history = messages_to_string(stored_messages)
|
avg_line_len = max(avg_line_len, 1.0)
|
||||||
chat_history = f"Chat Summary:\n{session.summary}\n\nRecent Messages:\n{recent_chat_history}" if session.summary else f"Recent Messages:\n{recent_chat_history}"
|
|
||||||
|
budget_based = int(max_chars / avg_line_len)
|
||||||
|
adaptive = max(DEFAULT_PROMPT_HISTORY_MIN_MESSAGES, budget_based)
|
||||||
|
adaptive = min(max_messages, adaptive)
|
||||||
|
return max(1, adaptive)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chat_history(
|
||||||
|
session,
|
||||||
|
max_messages=DEFAULT_PROMPT_HISTORY_MAX_MESSAGES,
|
||||||
|
max_chars=DEFAULT_PROMPT_HISTORY_MAX_CHARS,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return prompt-ready chat history with adaptive windowing and hard budget limits.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. Fetch a bounded recent slice from DB (performance guard).
|
||||||
|
2. Estimate adaptive message count from observed rendered message density.
|
||||||
|
3. Keep only the newest `adaptive_limit` messages.
|
||||||
|
4. Pack those lines under `max_chars` exactly.
|
||||||
|
"""
|
||||||
|
# Storage remains complete; only prompt context is reduced.
|
||||||
|
fetch_limit = max(max_messages * 3, 200)
|
||||||
|
fetch_limit = min(fetch_limit, 1000)
|
||||||
|
stored_messages = await sync_to_async(list)(
|
||||||
|
Message.objects.filter(session=session, user=session.user).order_by("-ts")[
|
||||||
|
:fetch_limit
|
||||||
|
]
|
||||||
|
)
|
||||||
|
stored_messages.reverse()
|
||||||
|
adaptive_limit = _compute_adaptive_message_limit(
|
||||||
|
stored_messages,
|
||||||
|
max_messages=max_messages,
|
||||||
|
max_chars=max_chars,
|
||||||
|
)
|
||||||
|
selected_messages = stored_messages[-adaptive_limit:]
|
||||||
|
recent_chat_history = _build_recent_history(selected_messages, max_chars=max_chars)
|
||||||
|
chat_history = f"Recent Messages:\n{recent_chat_history}"
|
||||||
|
|
||||||
return chat_history
|
return chat_history
|
||||||
|
|
||||||
|
|
||||||
async def get_chat_session(user, identifier):
|
async def get_chat_session(user, identifier):
|
||||||
chat_session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
|
chat_session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
|
||||||
identifier=identifier,
|
identifier=identifier,
|
||||||
@@ -21,6 +138,7 @@ async def get_chat_session(user, identifier):
|
|||||||
)
|
)
|
||||||
return chat_session
|
return chat_session
|
||||||
|
|
||||||
|
|
||||||
async def store_message(session, sender, text, ts, outgoing=False):
|
async def store_message(session, sender, text, ts, outgoing=False):
|
||||||
log.info(f"STORE MESSAGE {text}")
|
log.info(f"STORE MESSAGE {text}")
|
||||||
msg = await sync_to_async(Message.objects.create)(
|
msg = await sync_to_async(Message.objects.create)(
|
||||||
@@ -29,11 +147,12 @@ async def store_message(session, sender, text, ts, outgoing=False):
|
|||||||
sender_uuid=sender,
|
sender_uuid=sender,
|
||||||
text=text,
|
text=text,
|
||||||
ts=ts,
|
ts=ts,
|
||||||
custom_author="USER" if outgoing else None
|
custom_author="USER" if outgoing else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
async def store_own_message(session, text, ts, manip=None, queue=False):
|
async def store_own_message(session, text, ts, manip=None, queue=False):
|
||||||
log.info(f"STORE OWN MESSAGE {text}")
|
log.info(f"STORE OWN MESSAGE {text}")
|
||||||
cast = {
|
cast = {
|
||||||
@@ -53,4 +172,8 @@ async def store_own_message(session, text, ts, manip=None, queue=False):
|
|||||||
**cast,
|
**cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_queryset(queryset):
|
||||||
|
await sync_to_async(queryset.delete, thread_sensitive=True)()
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
import random
|
||||||
|
|
||||||
async def natural_send_message(text,
|
|
||||||
send,
|
async def natural_send_message(
|
||||||
start_typing,
|
text, send, start_typing, stop_typing, skip_thinking=False
|
||||||
stop_typing,
|
):
|
||||||
skip_thinking=False
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Parses and sends messages with natural delays based on message length.
|
Parses and sends messages with natural delays based on message length.
|
||||||
|
|
||||||
@@ -41,7 +39,9 @@ async def natural_send_message(text,
|
|||||||
# Decide when to start thinking *before* typing
|
# Decide when to start thinking *before* typing
|
||||||
if not skip_thinking:
|
if not skip_thinking:
|
||||||
if natural_delay > 3.5: # Only delay if response is long
|
if natural_delay > 3.5: # Only delay if response is long
|
||||||
await asyncio.sleep(natural_delay - 3.5) # "Thinking" pause before typing
|
await asyncio.sleep(
|
||||||
|
natural_delay - 3.5
|
||||||
|
) # "Thinking" pause before typing
|
||||||
|
|
||||||
# Start typing
|
# Start typing
|
||||||
await start_typing()
|
await start_typing()
|
||||||
@@ -55,4 +55,4 @@ async def natural_send_message(text,
|
|||||||
# Optional: Small buffer between messages to prevent rapid-fire responses
|
# Optional: Small buffer between messages to prevent rapid-fire responses
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
return ids
|
return ids
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
from core.lib.prompts import bases
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from core.models import Message, ChatSession, AI, Person, Manipulation
|
|
||||||
from core.util import logs
|
|
||||||
import json
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from django.utils import timezone
|
import json
|
||||||
import random
|
import random
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.lib.prompts import bases
|
||||||
|
from core.models import AI, ChatSession, Manipulation, Message, Person
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("replies")
|
log = logs.get_logger("replies")
|
||||||
|
|
||||||
|
|
||||||
def should_reply(
|
def should_reply(
|
||||||
reply_to_self,
|
reply_to_self,
|
||||||
reply_to_others,
|
reply_to_others,
|
||||||
is_outgoing_message,
|
is_outgoing_message,
|
||||||
):
|
):
|
||||||
reply = False
|
reply = False
|
||||||
if reply_to_self:
|
if reply_to_self:
|
||||||
@@ -26,7 +29,14 @@ def should_reply(
|
|||||||
|
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
def generate_mutate_reply_prompt(msg: dict, person: Person, manip: Manipulation, chat_history: str, mutate: bool = False):
|
|
||||||
|
def generate_mutate_reply_prompt(
|
||||||
|
msg: dict,
|
||||||
|
person: Person,
|
||||||
|
manip: Manipulation,
|
||||||
|
chat_history: str,
|
||||||
|
mutate: bool = False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Strictly rewrites the message in the persona’s tone and style
|
Strictly rewrites the message in the persona’s tone and style
|
||||||
while keeping the original meaning. No added explanations.
|
while keeping the original meaning. No added explanations.
|
||||||
@@ -66,16 +76,12 @@ def generate_mutate_reply_prompt(msg: dict, person: Person, manip: Manipulation,
|
|||||||
f"- **Response Tactics:** {persona.response_tactics}\n"
|
f"- **Response Tactics:** {persona.response_tactics}\n"
|
||||||
f"- **Persuasion Techniques:** {persona.persuasion_tactics}\n"
|
f"- **Persuasion Techniques:** {persona.persuasion_tactics}\n"
|
||||||
f"- **Boundaries:** {persona.boundaries} | **Adaptability:** {persona.adaptability}%\n\n"
|
f"- **Boundaries:** {persona.boundaries} | **Adaptability:** {persona.adaptability}%\n\n"
|
||||||
|
|
||||||
"### STRICT RULES ###\n"
|
"### STRICT RULES ###\n"
|
||||||
f"{strict_rules}\n\n"
|
f"{strict_rules}\n\n"
|
||||||
|
|
||||||
"### TRANSFORMATION GUIDELINES ###\n"
|
"### TRANSFORMATION GUIDELINES ###\n"
|
||||||
f"{transformation_guidelines}\n\n"
|
f"{transformation_guidelines}\n\n"
|
||||||
|
|
||||||
"### Original Message ###\n"
|
"### Original Message ###\n"
|
||||||
f"{msg}\n\n"
|
f"{msg}\n\n"
|
||||||
|
|
||||||
"### Rewritten Message ###\n"
|
"### Rewritten Message ###\n"
|
||||||
"(DO NOT include anything except the rewritten text. NO extra comments or formatting.)"
|
"(DO NOT include anything except the rewritten text. NO extra comments or formatting.)"
|
||||||
)
|
)
|
||||||
@@ -83,8 +89,13 @@ def generate_mutate_reply_prompt(msg: dict, person: Person, manip: Manipulation,
|
|||||||
return [{"role": "system", "content": system_message}]
|
return [{"role": "system", "content": system_message}]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reply_prompt(
|
||||||
def generate_reply_prompt(msg: dict, person: Person, manip: Manipulation, chat_history: str, mutate: bool = False):
|
msg: dict,
|
||||||
|
person: Person,
|
||||||
|
manip: Manipulation,
|
||||||
|
chat_history: str,
|
||||||
|
mutate: bool = False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Generate a structured prompt using the attributes of the provided Person and Manipulation models.
|
Generate a structured prompt using the attributes of the provided Person and Manipulation models.
|
||||||
"""
|
"""
|
||||||
@@ -108,7 +119,6 @@ def generate_reply_prompt(msg: dict, person: Person, manip: Manipulation, chat_h
|
|||||||
"You are my digital persona, responding on my behalf while embodying my personality, preferences, and unique style.\n\n"
|
"You are my digital persona, responding on my behalf while embodying my personality, preferences, and unique style.\n\n"
|
||||||
"You must strictly apply the following persona-based filtering rules when modifying the message:\n\n"
|
"You must strictly apply the following persona-based filtering rules when modifying the message:\n\n"
|
||||||
f"{filter_rules}\n\n"
|
f"{filter_rules}\n\n"
|
||||||
|
|
||||||
"### Persona Profile ###\n"
|
"### Persona Profile ###\n"
|
||||||
f"- **MBTI:** {persona.mbti} ({persona.mbti_identity} balance)\n"
|
f"- **MBTI:** {persona.mbti} ({persona.mbti_identity} balance)\n"
|
||||||
f"- **Tone:** {persona.tone} | **Humor:** {persona.humor_style}\n"
|
f"- **Tone:** {persona.tone} | **Humor:** {persona.humor_style}\n"
|
||||||
@@ -119,7 +129,6 @@ def generate_reply_prompt(msg: dict, person: Person, manip: Manipulation, chat_h
|
|||||||
f"- **Response Tactics:** {persona.response_tactics}\n"
|
f"- **Response Tactics:** {persona.response_tactics}\n"
|
||||||
f"- **Persuasion Techniques:** {persona.persuasion_tactics}\n"
|
f"- **Persuasion Techniques:** {persona.persuasion_tactics}\n"
|
||||||
f"- **Boundaries:** {persona.boundaries} | **Adaptability:** {persona.adaptability}%\n\n"
|
f"- **Boundaries:** {persona.boundaries} | **Adaptability:** {persona.adaptability}%\n\n"
|
||||||
|
|
||||||
"### Contact Information ###\n"
|
"### Contact Information ###\n"
|
||||||
f"- **Summary:** {person.summary or 'N/A'}\n"
|
f"- **Summary:** {person.summary or 'N/A'}\n"
|
||||||
f"- **Profile:** {person.profile or 'N/A'}\n"
|
f"- **Profile:** {person.profile or 'N/A'}\n"
|
||||||
@@ -128,7 +137,6 @@ def generate_reply_prompt(msg: dict, person: Person, manip: Manipulation, chat_h
|
|||||||
f"- **Timezone:** {person.timezone or 'N/A'}\n"
|
f"- **Timezone:** {person.timezone or 'N/A'}\n"
|
||||||
f"- **Last Interaction:** {person.last_interaction or 'Never'}\n"
|
f"- **Last Interaction:** {person.last_interaction or 'Never'}\n"
|
||||||
f"- **Current Date/Time:** {now}\n\n"
|
f"- **Current Date/Time:** {now}\n\n"
|
||||||
|
|
||||||
"### Conversation Context ###\n"
|
"### Conversation Context ###\n"
|
||||||
f"{chat_history if chat_history else 'No prior chat history.'}\n\n"
|
f"{chat_history if chat_history else 'No prior chat history.'}\n\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ def messages_to_string(messages: list):
|
|||||||
]
|
]
|
||||||
return "\n".join(message_texts)
|
return "\n".join(message_texts)
|
||||||
|
|
||||||
|
|
||||||
async def update_last_interaction(session):
|
async def update_last_interaction(session):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
session.identifier.person.last_interaction = now
|
session.identifier.person.last_interaction = now
|
||||||
session.last_interaction = now
|
session.last_interaction = now
|
||||||
await sync_to_async(session.identifier.person.save)()
|
await sync_to_async(session.identifier.person.save)()
|
||||||
await sync_to_async(session.save)()
|
await sync_to_async(session.save)()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Generated by Django 5.1.5 on 2025-02-06 21:57
|
# Generated by Django 5.1.5 on 2025-02-06 21:57
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Generated by Django 5.1.5 on 2025-02-07 12:05
|
# Generated by Django 5.1.5 on 2025-02-07 12:05
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Generated by Django 5.1.5 on 2025-02-07 13:56
|
# Generated by Django 5.1.5 on 2025-02-07 13:56
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Generated by Django 5.1.5 on 2025-02-08 16:07
|
# Generated by Django 5.1.5 on 2025-02-08 16:07
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-14 22:52
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0015_manipulation_filter_enabled_alter_manipulation_mode'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AIRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('window_spec', models.JSONField(default=dict)),
|
||||||
|
('message_ids', models.JSONField(blank=True, default=list)),
|
||||||
|
('user_notes', models.TextField(blank=True, default='')),
|
||||||
|
('operation', models.CharField(choices=[('summarise', 'Summarise'), ('draft_reply', 'Draft Reply'), ('critique', 'Critique'), ('repair', 'Repair'), ('extract_patterns', 'Extract Patterns'), ('memory_propose', 'Memory Propose')], max_length=32)),
|
||||||
|
('policy_snapshot', models.JSONField(blank=True, default=dict)),
|
||||||
|
('status', models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('done', 'Done'), ('failed', 'Failed')], default='queued', max_length=16)),
|
||||||
|
('error', models.TextField(blank=True, default='')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AIResult',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('summary_m3', models.TextField(blank=True, default='')),
|
||||||
|
('draft_replies', models.JSONField(blank=True, default=list)),
|
||||||
|
('risk_flags', models.JSONField(blank=True, default=list)),
|
||||||
|
('memory_proposals', models.JSONField(blank=True, default=list)),
|
||||||
|
('citations', models.JSONField(blank=True, default=list)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('ai_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='result', to='core.airequest')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WorkspaceConversation',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('title', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('platform_type', models.CharField(choices=[('signal', 'Signal'), ('instagram', 'Instagram')], default='signal', max_length=255)),
|
||||||
|
('platform_thread_id', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('last_event_ts', models.BigIntegerField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('participants', models.ManyToManyField(blank=True, related_name='workspace_conversations', to='core.person')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_conversations', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('ts', models.BigIntegerField(db_index=True)),
|
||||||
|
('direction', models.CharField(choices=[('in', 'Inbound'), ('out', 'Outbound')], max_length=8)),
|
||||||
|
('sender_uuid', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('text', models.TextField(blank=True, default='')),
|
||||||
|
('attachments', models.JSONField(blank=True, default=list)),
|
||||||
|
('raw_payload_ref', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.workspaceconversation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['ts'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MemoryItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('type', models.CharField(choices=[('M1', 'Durable Fact/Preference'), ('M2', 'Relationship State'), ('M3', 'Conversation Working Summary')], max_length=2)),
|
||||||
|
('status', models.CharField(choices=[('proposed', 'Proposed'), ('active', 'Active'), ('deprecated', 'Deprecated')], default='proposed', max_length=16)),
|
||||||
|
('content', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('source_request', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.airequest')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memory_items', to='core.workspaceconversation')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='conversation',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_requests', to='core.workspaceconversation'),
|
||||||
|
),
|
||||||
|
]
|
||||||
359
core/migrations/0017_remove_airesult_risk_flags_and_more.py
Normal file
359
core/migrations/0017_remove_airesult_risk_flags_and_more.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-15 00:14
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0016_airequest_airesult_workspaceconversation_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='risk_flags',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='summary_m3',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='type',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='interaction_signals',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text="Structured positive/neutral/risk signals inferred for this run. Example item: {'label':'repair_attempt','valence':'positive','message_event_ids':[...]}."),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(default=core.models.get_default_workspace_user_pk, help_text='Owner of this AI result row (required for restricted CRUD filtering).', on_delete=django.db.models.deletion.CASCADE, related_name='workspace_ai_results', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='working_summary',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Conversation working summary generated for this run.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='memory_kind',
|
||||||
|
field=models.CharField(choices=[('fact', 'Durable Fact/Preference'), ('state', 'Relationship State'), ('summary', 'Conversation Working Summary')], default=1, help_text='Memory kind: fact/state/summary.', max_length=16),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='source_system',
|
||||||
|
field=models.CharField(choices=[('signal', 'Signal'), ('xmpp', 'XMPP'), ('workspace', 'Workspace'), ('ai', 'AI')], default='signal', help_text='System that produced this event record.', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(default=core.models.get_default_workspace_user_pk, help_text='Owner of this message event row (required for restricted CRUD filtering).', on_delete=django.db.models.deletion.CASCADE, related_name='workspace_message_events', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='commitment_confidence',
|
||||||
|
field=models.FloatField(default=0.0, help_text='Confidence in commitment scores (0.0-1.0).'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='commitment_inbound_score',
|
||||||
|
field=models.FloatField(blank=True, help_text='Estimated commitment score for counterpart -> user direction (0-100). Null while calibrating.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='commitment_last_computed_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Timestamp of the latest commitment computation.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='commitment_outbound_score',
|
||||||
|
field=models.FloatField(blank=True, help_text='Estimated commitment score for user -> counterpart direction (0-100). Null while calibrating.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='last_ai_run_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Last time any AIRequest finished for this conversation.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='participant_feedback',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text="Per-person interaction feedback map keyed by person UUID. Example: {'<person_uuid>': {'state': 'withdrawing', 'note': 'short replies'}}."),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='stability_confidence',
|
||||||
|
field=models.FloatField(default=0.0, help_text='Confidence in stability_score (0.0-1.0).'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='stability_last_computed_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Timestamp of the latest stability computation.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='stability_sample_days',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='How many calendar days of data were used for stability.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='stability_sample_messages',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='How many messages were used to compute stability.'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='stability_score',
|
||||||
|
field=models.FloatField(blank=True, help_text='Relationship stability score (0-100). Null while calibrating.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='stability_state',
|
||||||
|
field=models.CharField(choices=[('calibrating', 'Calibrating'), ('stable', 'Stable'), ('watch', 'Watch'), ('fragile', 'Fragile')], default='calibrating', help_text='UI label for relationship stability, baseline-aware.', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='conversation',
|
||||||
|
field=models.ForeignKey(help_text='Conversation analyzed by this request.', on_delete=django.db.models.deletion.CASCADE, related_name='ai_requests', to='core.workspaceconversation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text='Request creation timestamp.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='error',
|
||||||
|
field=models.TextField(blank=True, default='', help_text="Error details when status='failed'."),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='finished_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Worker completion timestamp.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this AI request.', primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='message_ids',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='Resolved ordered MessageEvent IDs included in this run.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='operation',
|
||||||
|
field=models.CharField(choices=[('summarise', 'Summarise'), ('draft_reply', 'Draft Reply'), ('critique', 'Critique'), ('repair', 'Repair'), ('extract_patterns', 'Extract Patterns'), ('memory_propose', 'Memory Propose'), ('state_detect', 'State Detect'), ('rewrite_style', 'Rewrite Style'), ('send_readiness', 'Send Readiness'), ('timeline_brief', 'Timeline Brief')], help_text='Requested AI operation type.', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='policy_snapshot',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Effective manipulation/policy values captured at request time, so results remain auditable even if policies change later.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='started_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Worker start timestamp.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('done', 'Done'), ('failed', 'Failed')], default='queued', help_text='Worker lifecycle state for this request.', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(help_text='User who initiated this request.', on_delete=django.db.models.deletion.CASCADE, related_name='workspace_ai_requests', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='user_notes',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Optional user intent/context notes injected into the prompt.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airequest',
|
||||||
|
name='window_spec',
|
||||||
|
field=models.JSONField(default=dict, help_text='Selection spec (last_n/since_ts/between_ts/include_attachments/etc). Should be dynamically resolved by available context/token budget.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='ai_request',
|
||||||
|
field=models.OneToOneField(help_text='Owning AI request for this result.', on_delete=django.db.models.deletion.CASCADE, related_name='result', to='core.airequest'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='citations',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='Referenced MessageEvent IDs supporting generated claims.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text='Result creation timestamp.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='draft_replies',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='Draft reply candidates, typically with tone and rationale.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this AI result payload.', primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='airesult',
|
||||||
|
name='memory_proposals',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='Proposed memory entries, typically requiring user approval.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='content',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Structured memory payload (schema can evolve by type).'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='conversation',
|
||||||
|
field=models.ForeignKey(help_text='Conversation scope this memory item belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='memory_items', to='core.workspaceconversation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this memory item.', primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='source_request',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='AIRequest that originated this memory, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.airequest'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('proposed', 'Proposed'), ('active', 'Active'), ('deprecated', 'Deprecated')], default='proposed', help_text='Lifecycle state, especially for approval-gated memories.', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='updated_at',
|
||||||
|
field=models.DateTimeField(auto_now=True, help_text='Last update timestamp.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='memoryitem',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(help_text='Owner of the memory item.', on_delete=django.db.models.deletion.CASCADE, related_name='workspace_memory_items', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='attachments',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='Attachment metadata list associated with this message.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='conversation',
|
||||||
|
field=models.ForeignKey(help_text='AI workspace conversation this message belongs to. This is not the transport-native thread object.', on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.workspaceconversation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='direction',
|
||||||
|
field=models.CharField(choices=[('in', 'Inbound'), ('out', 'Outbound')], help_text="Direction relative to workspace owner: 'in' from counterpart(s), 'out' from user/bot side.", max_length=8),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this message event.', primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='raw_payload_ref',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Raw source payload or reference pointer for audit/debug.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='sender_uuid',
|
||||||
|
field=models.CharField(blank=True, db_index=True, default='', help_text='Source sender UUID/identifier for correlation.', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='text',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Normalized message text body.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='messageevent',
|
||||||
|
name='ts',
|
||||||
|
field=models.BigIntegerField(db_index=True, help_text='Event timestamp (unix ms) as reported by source_system.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='personidentifier',
|
||||||
|
name='service',
|
||||||
|
field=models.CharField(choices=[('signal', 'Signal'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='id',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this workspace conversation.', primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='last_event_ts',
|
||||||
|
field=models.BigIntegerField(blank=True, help_text='Latest message timestamp (unix ms) currently known.', null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='participants',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Resolved people participating in this conversation.', related_name='workspace_conversations', to='core.person'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='platform_thread_id',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Platform-native thread/group identifier when available.', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='platform_type',
|
||||||
|
field=models.CharField(choices=[('signal', 'Signal'), ('xmpp', 'XMPP'), ('instagram', 'Instagram')], default='signal', help_text='Primary transport for this conversation (reuses SERVICE_CHOICES).', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Human-friendly label shown in the workspace sidebar.', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='workspaceconversation',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(help_text='Owner of this conversation workspace.', on_delete=django.db.models.deletion.CASCADE, related_name='workspace_conversations', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AIResultSignal',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this result signal.', primary_key=True, serialize=False)),
|
||||||
|
('label', models.CharField(help_text="Short signal label, e.g. 'withdrawing', 'repair_attempt'.", max_length=128)),
|
||||||
|
('valence', models.CharField(choices=[('positive', 'Positive'), ('neutral', 'Neutral'), ('risk', 'Risk')], default='neutral', help_text='Signal polarity: positive, neutral, or risk.', max_length=16)),
|
||||||
|
('score', models.FloatField(blank=True, help_text='Optional model confidence/strength (0.0-1.0).', null=True)),
|
||||||
|
('rationale', models.TextField(blank=True, default='', help_text='Human-readable explanation for this signal.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('ai_result', models.ForeignKey(help_text='AI result that produced this signal.', on_delete=django.db.models.deletion.CASCADE, related_name='signals', to='core.airesult')),
|
||||||
|
('message_event', models.ForeignKey(blank=True, help_text='Optional specific message event referenced by this signal.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_signals', to='core.messageevent')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this signal row (required for restricted CRUD filtering).', on_delete=django.db.models.deletion.CASCADE, related_name='workspace_ai_result_signals', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-15 00:58
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0017_remove_airesult_risk_flags_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternMitigationPlan',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this mitigation plan.', primary_key=True, serialize=False)),
|
||||||
|
('title', models.CharField(blank=True, default='', help_text='Display title for this plan.', max_length=255)),
|
||||||
|
('objective', models.TextField(blank=True, default='', help_text='High-level objective this plan is meant to achieve.')),
|
||||||
|
('fundamental_items', models.JSONField(blank=True, default=list, help_text='Foundational agreed items/principles for this plan.')),
|
||||||
|
('creation_mode', models.CharField(choices=[('auto', 'Auto'), ('guided', 'Guided')], default='auto', help_text='Whether plan artifacts were generated automatically or user-guided.', max_length=16)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('active', 'Active'), ('archived', 'Archived')], default='draft', help_text='Lifecycle status of the plan.', max_length=16)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Last update timestamp.')),
|
||||||
|
('conversation', models.ForeignKey(help_text='Workspace conversation this plan belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='mitigation_plans', to='core.workspaceconversation')),
|
||||||
|
('source_ai_result', models.ForeignKey(blank=True, help_text='AI result that initiated this plan, if any.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mitigation_plans', to='core.airesult')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this plan.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_mitigation_plans', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternMitigationMessage',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this mitigation message.', primary_key=True, serialize=False)),
|
||||||
|
('role', models.CharField(choices=[('user', 'User'), ('assistant', 'Assistant'), ('system', 'System')], help_text='Message speaker role.', max_length=16)),
|
||||||
|
('text', models.TextField(help_text='Message content.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this message.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_mitigation_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('plan', models.ForeignKey(help_text='Parent mitigation plan.', on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.patternmitigationplan')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternMitigationGame',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this game.', primary_key=True, serialize=False)),
|
||||||
|
('title', models.CharField(help_text='Game title.', max_length=255)),
|
||||||
|
('instructions', models.TextField(blank=True, default='', help_text='Gameplay/instruction text.')),
|
||||||
|
('enabled', models.BooleanField(default=True, help_text='Whether this game is currently enabled.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this game.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_mitigation_games', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('plan', models.ForeignKey(help_text='Parent mitigation plan.', on_delete=django.db.models.deletion.CASCADE, related_name='games', to='core.patternmitigationplan')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternArtifactExport',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this export artifact.', primary_key=True, serialize=False)),
|
||||||
|
('artifact_type', models.CharField(choices=[('rulebook', 'Rulebook'), ('rules', 'Rules'), ('games', 'Games')], help_text='Artifact category being exported.', max_length=32)),
|
||||||
|
('export_format', models.CharField(choices=[('markdown', 'Markdown'), ('json', 'JSON'), ('text', 'Text')], default='markdown', help_text='Serialized output format.', max_length=16)),
|
||||||
|
('protocol_version', models.CharField(default='artifact-v1', help_text='Artifact export protocol version.', max_length=32)),
|
||||||
|
('payload', models.TextField(blank=True, default='', help_text='Serialized artifact body/content.')),
|
||||||
|
('meta', models.JSONField(blank=True, default=dict, help_text='Additional export metadata (counts, hints, source IDs).')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this export artifact.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_artifact_exports', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('plan', models.ForeignKey(help_text='Source mitigation plan.', on_delete=django.db.models.deletion.CASCADE, related_name='exports', to='core.patternmitigationplan')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternMitigationRule',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this rule.', primary_key=True, serialize=False)),
|
||||||
|
('title', models.CharField(help_text='Rule title.', max_length=255)),
|
||||||
|
('content', models.TextField(blank=True, default='', help_text='Rule definition/details.')),
|
||||||
|
('enabled', models.BooleanField(default=True, help_text='Whether this rule is currently enabled.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('plan', models.ForeignKey(help_text='Parent mitigation plan.', on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='core.patternmitigationplan')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this rule.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_mitigation_rules', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
28
core/migrations/0019_patternmitigationcorrection.py
Normal file
28
core/migrations/0019_patternmitigationcorrection.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-15 01:13
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0018_patternmitigationplan_patternmitigationmessage_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternMitigationCorrection',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this correction item.', primary_key=True, serialize=False)),
|
||||||
|
('title', models.CharField(help_text='Correction title.', max_length=255)),
|
||||||
|
('clarification', models.TextField(blank=True, default='', help_text='Joint clarification text intended to reduce interpretation drift.')),
|
||||||
|
('enabled', models.BooleanField(default=True, help_text='Whether this correction item is currently enabled.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('plan', models.ForeignKey(help_text='Parent mitigation plan.', on_delete=django.db.models.deletion.CASCADE, related_name='corrections', to='core.patternmitigationplan')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this correction item.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_mitigation_corrections', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-15 01:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0019_patternmitigationcorrection'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='language_style',
|
||||||
|
field=models.CharField(choices=[('same', 'Same Language'), ('adapted', 'Adapted Language')], default='adapted', help_text='Whether to keep wording identical or adapt it per recipient.', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='perspective',
|
||||||
|
field=models.CharField(choices=[('third_person', 'Third Person'), ('first_person', 'First Person')], default='third_person', help_text='Narrative perspective used when framing this correction.', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='share_target',
|
||||||
|
field=models.CharField(choices=[('self', 'Self'), ('other', 'Other Party'), ('both', 'Both Parties')], default='both', help_text='Who this insight is intended to be shared with.', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='source_phrase',
|
||||||
|
field=models.TextField(blank=True, default='', help_text="Situation/message fragment this correction responds to, e.g. 'she says ...'."),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternartifactexport',
|
||||||
|
name='artifact_type',
|
||||||
|
field=models.CharField(choices=[('rulebook', 'Rulebook'), ('rules', 'Rules'), ('games', 'Games'), ('corrections', 'Corrections')], help_text='Artifact category being exported.', max_length=32),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-15 02:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0020_patternmitigationcorrection_language_style_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='clarification',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Joint clarification text intended to reduce interpretation drift. Example: \'When you say "you ignore me", I hear fear of disconnection, not blame.\''),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='language_style',
|
||||||
|
field=models.CharField(choices=[('same', 'Same Language'), ('adapted', 'Adapted Language')], default='adapted', help_text='Whether to keep wording identical or adapt it per recipient. Example: same text for both parties, or softened/adapted wording for recipient.', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='perspective',
|
||||||
|
field=models.CharField(choices=[('third_person', 'Third Person'), ('second_person', 'Second Person'), ('first_person', 'First Person')], default='third_person', help_text="Narrative perspective used when framing this correction. Examples: third person ('she says'), second person ('you say'), first person ('I say').", max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='share_target',
|
||||||
|
field=models.CharField(choices=[('self', 'Self'), ('other', 'Other Party'), ('both', 'Both Parties')], default='both', help_text='Who this insight is intended to be shared with. Example: self, other, or both.', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='source_phrase',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Situation/message fragment this correction responds to. Example: \'she says: "you never listen"\' or \'you say: "you are dismissing me"\'.'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='patternmitigationcorrection',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(help_text="Correction title. Example: 'Assumption vs intent mismatch'.", max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
38
core/migrations/0022_patternmitigationautosettings.py
Normal file
38
core/migrations/0022_patternmitigationautosettings.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-15 02:38
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0021_alter_patternmitigationcorrection_clarification_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PatternMitigationAutoSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable identifier for this automation settings row.', primary_key=True, serialize=False)),
|
||||||
|
('enabled', models.BooleanField(default=False, help_text='Master toggle for mitigation automation in this conversation.')),
|
||||||
|
('auto_pattern_recognition', models.BooleanField(default=True, help_text='Run pattern/violation recognition automatically when triggered.')),
|
||||||
|
('auto_create_mitigation', models.BooleanField(default=False, help_text='Create a baseline mitigation plan automatically when missing.')),
|
||||||
|
('auto_create_corrections', models.BooleanField(default=False, help_text='Create correction items automatically from detected violations.')),
|
||||||
|
('auto_notify_enabled', models.BooleanField(default=False, help_text='Send NTFY notifications when new violations are detected.')),
|
||||||
|
('ntfy_topic_override', models.CharField(blank=True, help_text='Optional NTFY topic override for automation notifications.', max_length=255, null=True)),
|
||||||
|
('ntfy_url_override', models.CharField(blank=True, help_text='Optional NTFY server URL override for automation notifications.', max_length=255, null=True)),
|
||||||
|
('sample_message_window', models.PositiveIntegerField(default=40, help_text='How many recent messages to include in each automation check.')),
|
||||||
|
('check_cooldown_seconds', models.PositiveIntegerField(default=300, help_text='Minimum seconds between automatic checks for this conversation.')),
|
||||||
|
('last_checked_event_ts', models.BigIntegerField(blank=True, help_text='Latest source message timestamp included in automation checks.', null=True)),
|
||||||
|
('last_run_at', models.DateTimeField(blank=True, help_text='Timestamp when automation last ran.', null=True)),
|
||||||
|
('last_result_summary', models.TextField(blank=True, default='', help_text='Human-readable summary from the last automation run.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, help_text='Row creation timestamp.')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, help_text='Last update timestamp.')),
|
||||||
|
('conversation', models.OneToOneField(help_text='Conversation scope this automation config applies to.', on_delete=django.db.models.deletion.CASCADE, related_name='mitigation_auto_settings', to='core.workspaceconversation')),
|
||||||
|
('user', models.ForeignKey(help_text='Owner of this automation settings row.', on_delete=django.db.models.deletion.CASCADE, related_name='pattern_mitigation_auto_settings', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
1129
core/models.py
1129
core/models.py
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
from core.util import logs
|
|
||||||
|
|
||||||
from core.clients.signal import SignalClient
|
from core.clients.signal import SignalClient
|
||||||
from core.clients.xmpp import XMPPClient
|
from core.clients.xmpp import XMPPClient
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
|
|
||||||
class UnifiedRouter(object):
|
class UnifiedRouter(object):
|
||||||
"""
|
"""
|
||||||
@@ -22,10 +22,9 @@ class UnifiedRouter(object):
|
|||||||
self.xmpp.start()
|
self.xmpp.start()
|
||||||
self.signal.start()
|
self.signal.start()
|
||||||
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
#self.xmpp.client.client.process()
|
# self.xmpp.client.client.process()
|
||||||
# self.xmpp.start()
|
# self.xmpp.start()
|
||||||
print("IN RUN BEFORE START")
|
print("IN RUN BEFORE START")
|
||||||
self._start()
|
self._start()
|
||||||
|
|||||||
@@ -234,7 +234,6 @@
|
|||||||
<a class="navbar-item" href="{% url 'home' %}">
|
<a class="navbar-item" href="{% url 'home' %}">
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
@@ -284,9 +283,6 @@
|
|||||||
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
|
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
|
||||||
Sessions
|
Sessions
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="{% url 'queues' type='page' %}">
|
|
||||||
Queued Messages
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -311,6 +307,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||||
|
AI
|
||||||
|
</a>
|
||||||
|
<a class="navbar-item" href="{% url 'queues' type='page' %}">
|
||||||
|
Queue
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
{% if not user.is_authenticated %}
|
{% if not user.is_authenticated %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<script>
|
<script>
|
||||||
var grid = GridStack.init({
|
var grid = GridStack.init({
|
||||||
cellHeight: 20,
|
cellHeight: 20,
|
||||||
cellWidth: 50,
|
cellWidth: 45,
|
||||||
cellHeightUnit: 'px',
|
cellHeightUnit: 'px',
|
||||||
auto: true,
|
auto: true,
|
||||||
float: true,
|
float: true,
|
||||||
@@ -78,9 +78,9 @@
|
|||||||
// }
|
// }
|
||||||
grid.compact();
|
grid.compact();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<div>
|
<div>
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<!-- <div
|
<!-- <div
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="#"
|
hx-get="#"
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="afterend"
|
hx-swap="afterend"
|
||||||
style="display: none;"></div> -->
|
style="display: none;"></div> -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
11
core/templates/pages/ai-workspace.html
Normal file
11
core/templates/pages/ai-workspace.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "index.html" %}
|
||||||
|
|
||||||
|
{% block load_widgets %}
|
||||||
|
<div
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{% url 'ai_workspace_contacts' type='widget' %}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="afterend"
|
||||||
|
style="display: none;"></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "index.html" %}
|
{% extends "index.html" %}
|
||||||
|
|
||||||
{% block load_widgets %}
|
{% block load_widgets %}
|
||||||
<div
|
<div
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="{% url 'signal_accounts' type='widget' %}"
|
hx-get="{% url 'signal_accounts' type='widget' %}"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
|
|||||||
213
core/templates/partials/ai-workspace-ai-result.html
Normal file
213
core/templates/partials/ai-workspace-ai-result.html
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<div style="margin-bottom: 0.5rem;">
|
||||||
|
<div class="tags has-addons" style="display: inline-flex; margin-bottom: 0.4rem;">
|
||||||
|
<span class="tag is-dark">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<span class="tag is-white" style="border: 1px solid rgba(0, 0, 0, 0.2);">
|
||||||
|
AI {{ operation_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="notification is-danger is-light" style="padding: 0.6rem;">
|
||||||
|
{{ result_text }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if operation == "artifacts" %}
|
||||||
|
{% if latest_plan %}
|
||||||
|
{% include "partials/ai-workspace-mitigation-panel.html" with person=person plan=latest_plan rules=latest_plan_rules games=latest_plan_games corrections=latest_plan_corrections fundamentals_text=latest_plan.fundamental_items|join:"\n" mitigation_messages=latest_plan_messages latest_export=latest_plan_export notice_message=mitigation_notice_message notice_level=mitigation_notice_level auto_settings=latest_auto_settings active_tab="plan_board" %}
|
||||||
|
{% else %}
|
||||||
|
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.2rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">No mitigation plan yet. Use the Patterns tab to generate one.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif operation == "draft_reply" and draft_replies %}
|
||||||
|
<div id="draft-host-{{ person.id }}-{{ operation }}" data-selected="0">
|
||||||
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
|
{% for option in draft_replies %}
|
||||||
|
<div class="column is-12-mobile is-4-tablet" style="padding: 0.35rem;">
|
||||||
|
<article
|
||||||
|
class="draft-option-card {% if forloop.first %}is-selected{% endif %}"
|
||||||
|
data-index="{{ forloop.counter0 }}"
|
||||||
|
onclick="giaWorkspaceUseDraft('{{ person.id }}', '{{ operation }}', {{ forloop.counter0 }}); return false;"
|
||||||
|
style="height: 100%; padding: 0.6rem; border-radius: 9px; border: 1px solid rgba(0, 0, 0, 0.16); background: #fff; cursor: pointer; transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold is-flex is-align-items-center" style="margin-bottom: 0.35rem; gap: 0.35rem;">
|
||||||
|
{% with tone=option.label|default:""|lower %}
|
||||||
|
{% if tone == "soft" %}
|
||||||
|
<span class="icon is-small has-text-success"><i class="fa-solid fa-leaf"></i></span>
|
||||||
|
{% elif tone == "neutral" %}
|
||||||
|
<span class="icon is-small has-text-info"><i class="fa-solid fa-scale-balanced"></i></span>
|
||||||
|
{% elif tone == "firm" %}
|
||||||
|
<span class="icon is-small has-text-danger"><i class="fa-solid fa-shield-heart"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="icon is-small has-text-grey"><i class="fa-solid fa-comment-dots"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<span>{{ option.label|default:"Option" }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="draft-text" style="white-space: pre-wrap; margin-bottom: 0;">{{ option.text }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="draft-send-shell-{{ person.id }}-{{ operation }}" style="margin-top: 0.5rem; padding: 0.6rem; border: 1px solid rgba(0, 0, 0, 0.16); border-radius: 8px;">
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_send' type='widget' person_id=person.id %}"
|
||||||
|
hx-target="#draft-send-status-{{ person.id }}-{{ operation }}"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<input type="hidden" id="draft-send-input-{{ person.id }}-{{ operation }}" name="draft_text" value="">
|
||||||
|
<input type="hidden" id="draft-send-force-{{ person.id }}-{{ operation }}" name="force_send" value="0">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Draft Preview</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea id="draft-send-preview-{{ person.id }}-{{ operation }}" class="textarea is-small" rows="4" readonly></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom: 0.4rem;">
|
||||||
|
<div class="control buttons are-small" style="margin: 0; gap: 0.35rem;">
|
||||||
|
<button id="draft-send-btn-{{ person.id }}-{{ operation }}" class="button is-small is-link" {% if not send_state.can_send %}disabled{% endif %}>
|
||||||
|
Send Draft
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-info is-light"
|
||||||
|
onclick="giaWorkspaceQueueSelectedDraft('{{ person.id }}'); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
|
||||||
|
<span>Add To Queue</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="draft-send-status-{{ person.id }}-{{ operation }}"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if operation == "extract_patterns" %}
|
||||||
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
|
{% for section in result_sections %}
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
||||||
|
<article class="box ai-section-box" style="height: 100%; padding: 0.65rem; margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||||
|
{% if section.level <= 2 %}
|
||||||
|
<h3 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h3>
|
||||||
|
{% elif section.level == 3 %}
|
||||||
|
<h4 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h4>
|
||||||
|
{% else %}
|
||||||
|
<h5 class="subtitle is-7 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ section.title }}</h5>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for block in section.blocks %}
|
||||||
|
{% if block.type == "ul" %}
|
||||||
|
<ul style="margin: 0 0 0.45rem 1.15rem;">
|
||||||
|
{% for item in block.items %}
|
||||||
|
<li style="margin-bottom: 0.25rem;">{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{% for item in block.items %}
|
||||||
|
<p style="margin-bottom: 0.45rem; white-space: pre-wrap;">{{ item }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="ai-section-stack">
|
||||||
|
{% for section in result_sections %}
|
||||||
|
<article class="box ai-section-box" style="padding: 0.65rem; margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||||
|
{% if section.level <= 2 %}
|
||||||
|
<h3 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h3>
|
||||||
|
{% elif section.level == 3 %}
|
||||||
|
<h4 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h4>
|
||||||
|
{% else %}
|
||||||
|
<h5 class="subtitle is-7 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ section.title }}</h5>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for block in section.blocks %}
|
||||||
|
{% if block.type == "ul" %}
|
||||||
|
<ul style="margin: 0 0 0.45rem 1.15rem;">
|
||||||
|
{% for item in block.items %}
|
||||||
|
<li style="margin-bottom: 0.25rem;">{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{% for item in block.items %}
|
||||||
|
<p style="margin-bottom: 0.45rem; white-space: pre-wrap;">{{ item }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if operation == "extract_patterns" %}
|
||||||
|
<article class="box" style="padding: 0.7rem; margin-top: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Create Framework / Rules / Games</p>
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_create' type='widget' person_id=person.id %}"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#mitigation-create-loading-{{ person.id }}"
|
||||||
|
style="margin-bottom: 0;">
|
||||||
|
<input type="hidden" name="ai_result_id" value="{{ ai_result_id|default:'' }}">
|
||||||
|
<textarea name="source_text" style="display: none;">{{ result_text }}</textarea>
|
||||||
|
|
||||||
|
<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="label is-small" style="margin-bottom: 0.25rem;">Output</label>
|
||||||
|
<div class="select is-fullwidth is-small">
|
||||||
|
<select name="output_profile" required>
|
||||||
|
<option value="" selected disabled>Choose one</option>
|
||||||
|
<option value="framework">Framework (balanced)</option>
|
||||||
|
<option value="rule">Rule (minimal + strict)</option>
|
||||||
|
<option value="game">Game (engaging)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Context (single freeform input)</label>
|
||||||
|
<textarea class="textarea is-small" rows="2" name="user_context" placeholder="Optional context or constraints"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button is-small is-primary is-light">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-chess-board"></i></span>
|
||||||
|
<span>Create Mitigation Plan</span>
|
||||||
|
</button>
|
||||||
|
<span id="mitigation-create-loading-{{ person.id }}" class="tag is-info is-light htmx-indicator" style="margin-left: 0.45rem;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-spinner fa-spin"></i></span>
|
||||||
|
<span>Building mitigation plan...</span>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">
|
||||||
|
Plan editing is consolidated in the <strong>Plan</strong> tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.draft-option-card.is-selected {
|
||||||
|
border-color: rgba(54, 54, 54, 0.85) !important;
|
||||||
|
border-width: 2px !important;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(54, 54, 54, 0.18);
|
||||||
|
background-color: rgba(54, 54, 54, 0.06) !important;
|
||||||
|
}
|
||||||
|
.htmx-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.htmx-request.htmx-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
660
core/templates/partials/ai-workspace-mitigation-panel.html
Normal file
660
core/templates/partials/ai-workspace-mitigation-panel.html
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
<div id="mitigation-shell-{{ person.id }}" style="margin-top: 0.7rem;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-start" style="gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<p class="is-size-7 has-text-weight-semibold">Pattern Mitigation</p>
|
||||||
|
<h4 class="title is-6" style="margin-bottom: 0.2rem;">{{ plan.title|default:"Mitigation Plan" }}</h4>
|
||||||
|
{% if plan.objective %}
|
||||||
|
<p class="is-size-7">{{ plan.objective }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="tag is-light">{{ plan.creation_mode|title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if notice_message %}
|
||||||
|
<div class="notification is-{{ notice_level|default:'info' }} is-light" style="padding: 0.5rem 0.65rem; margin-bottom: 0.55rem;">
|
||||||
|
{{ notice_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0.55rem;">
|
||||||
|
<ul>
|
||||||
|
<li id="mitigation-tab-btn-{{ person.id }}-plan_board" class="is-active">
|
||||||
|
<a onclick="giaMitigationShowTab('{{ person.id }}', 'plan_board'); return false;">Rules & Games</a>
|
||||||
|
</li>
|
||||||
|
<li id="mitigation-tab-btn-{{ person.id }}-corrections">
|
||||||
|
<a onclick="giaMitigationShowTab('{{ person.id }}', 'corrections'); return false;">Corrections</a>
|
||||||
|
</li>
|
||||||
|
<li id="mitigation-tab-btn-{{ person.id }}-engage">
|
||||||
|
<a onclick="giaMitigationShowTab('{{ person.id }}', 'engage'); return false;">Engage</a>
|
||||||
|
</li>
|
||||||
|
<li id="mitigation-tab-btn-{{ person.id }}-fundamentals">
|
||||||
|
<a onclick="giaMitigationShowTab('{{ person.id }}', 'fundamentals'); return false;">Fundamentals</a>
|
||||||
|
</li>
|
||||||
|
<li id="mitigation-tab-btn-{{ person.id }}-auto">
|
||||||
|
<a onclick="giaMitigationShowTab('{{ person.id }}', 'auto'); return false;">Auto</a>
|
||||||
|
</li>
|
||||||
|
<li id="mitigation-tab-btn-{{ person.id }}-ask_ai">
|
||||||
|
<a onclick="giaMitigationShowTab('{{ person.id }}', 'ask_ai'); return false;">Ask AI</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mitigation-tab-{{ person.id }}-plan_board" class="mitigation-tab-pane">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.45rem; flex-wrap: wrap;">
|
||||||
|
<p class="is-size-7">Two lanes by type: rules on the left, games on the right.</p>
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_create' type='widget' person_id=person.id plan_id=plan.id kind='rule' %}"
|
||||||
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-plus"></i></span>
|
||||||
|
<span>Rule</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_create' type='widget' person_id=person.id plan_id=plan.id kind='game' %}"
|
||||||
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-plus"></i></span>
|
||||||
|
<span>Game</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
||||||
|
<article class="box" style="min-height: 14rem; border: 1px solid rgba(0, 0, 0, 0.15); box-shadow: none;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
|
||||||
|
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">RULES</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-danger is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_delete_all' type='widget' person_id=person.id plan_id=plan.id kind='rule' %}"
|
||||||
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
|
hx-confirm="Delete all rules?"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">Delete All</button>
|
||||||
|
</div>
|
||||||
|
{% for rule in rules %}
|
||||||
|
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||||
|
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Rule</span>
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="field" style="margin-bottom: 0.35rem;">
|
||||||
|
<input class="input is-small" type="text" name="title" value="{{ rule.title }}" data-editable="1" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom: 0.35rem;">
|
||||||
|
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ rule.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="enabled" value="1">
|
||||||
|
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-danger is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
|
||||||
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
|
hx-confirm="Delete this rule?"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">No rules yet.</p>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
|
||||||
|
<article class="box" style="min-height: 14rem; border: 1px solid rgba(0, 0, 0, 0.15); box-shadow: none;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
|
||||||
|
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">GAMES</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-danger is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_delete_all' type='widget' person_id=person.id plan_id=plan.id kind='game' %}"
|
||||||
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
|
hx-confirm="Delete all games?"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">Delete All</button>
|
||||||
|
</div>
|
||||||
|
{% for game in games %}
|
||||||
|
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||||
|
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Game</span>
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="field" style="margin-bottom: 0.35rem;">
|
||||||
|
<input class="input is-small" type="text" name="title" value="{{ game.title }}" data-editable="1" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom: 0.35rem;">
|
||||||
|
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ game.instructions }}</textarea>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="enabled" value="1">
|
||||||
|
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-danger is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
|
||||||
|
hx-vals='{"active_tab":"plan_board"}'
|
||||||
|
hx-confirm="Delete this game?"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">No games yet.</p>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mitigation-tab-{{ person.id }}-corrections" class="mitigation-tab-pane" style="display: none;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.45rem; flex-wrap: wrap;">
|
||||||
|
<p class="is-size-7">Corrections capture situation-specific clarification points.</p>
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button
|
||||||
|
class="button is-small is-light"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_artifact_create' type='widget' person_id=person.id plan_id=plan.id kind='correction' %}"
|
||||||
|
hx-vals='{"active_tab":"corrections"}'
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-plus"></i></span>
|
||||||
|
<span>Correction</span>
|
||||||
|
</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_all' type='widget' person_id=person.id plan_id=plan.id kind='correction' %}"
|
||||||
|
hx-vals='{"active_tab":"corrections"}'
|
||||||
|
hx-confirm="Delete all corrections?"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML">Delete All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<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 }}">
|
||||||
|
</div>
|
||||||
|
<div class="column is-12" style="padding: 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>
|
||||||
|
</div>
|
||||||
|
<div class="column is-12" style="padding: 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="enabled" value="1">
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">No corrections yet.</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mitigation-tab-{{ person.id }}-engage" class="mitigation-tab-pane" style="display: none;">
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none; margin-bottom: 0.55rem;">
|
||||||
|
<p class="is-size-7" style="margin-bottom: 0.45rem;">
|
||||||
|
Build a share-ready message from a rule, game, or correction. Voice framing now lives here.
|
||||||
|
</p>
|
||||||
|
<p class="is-size-7" style="margin-bottom: 0;"><strong>Send:</strong> {{ send_state.text }}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="engage-form-{{ person.id }}"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_engage_share' 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:'engage' }}">
|
||||||
|
<input type="hidden" id="engage-action-input-{{ person.id }}" name="action" value="preview">
|
||||||
|
<input type="hidden" id="engage-force-send-{{ person.id }}" name="force_send" value="0">
|
||||||
|
<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="label is-small" style="margin-bottom: 0.25rem;">Source</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select name="source_ref" required onchange="giaEngageAutoPreview('{{ person.id }}');">
|
||||||
|
{% if engage_options %}
|
||||||
|
{% for option in engage_options %}
|
||||||
|
<option value="{{ option.value }}" {% if option.value == engage_form.source_ref %}selected{% endif %}>{{ option.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for rule in rules %}
|
||||||
|
<option value="rule:{{ rule.id }}">Rule: {{ rule.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% for game in games %}
|
||||||
|
<option value="game:{{ game.id }}">Game: {{ game.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% for correction in corrections %}
|
||||||
|
<option value="correction:{{ correction.id }}">Correction: {{ correction.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Framing</label>
|
||||||
|
<input type="hidden" id="engage-framing-input-{{ person.id }}" name="framing" value="{{ engage_form.framing|default:'dont_change' }}">
|
||||||
|
<div id="engage-framing-tabs-{{ person.id }}" class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
|
||||||
|
<ul>
|
||||||
|
<li class="{% if engage_form.framing == 'dont_change' or engage_form.framing == 'neutral' or engage_form.framing == 'named' or not engage_form.framing %}is-active{% endif %}">
|
||||||
|
<a onclick="giaEngageSelect('{{ person.id }}', 'framing', 'dont_change', this); return false;">Don't Change</a>
|
||||||
|
</li>
|
||||||
|
<li class="{% if engage_form.framing == 'shared' %}is-active{% endif %}">
|
||||||
|
<a onclick="giaEngageSelect('{{ person.id }}', 'framing', 'shared', this); return false;">Shared (We/Us)</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Share With</label>
|
||||||
|
<input type="hidden" id="engage-share-input-{{ person.id }}" name="share_target" value="{{ engage_form.share_target|default:'self' }}">
|
||||||
|
<div id="engage-share-tabs-{{ person.id }}" class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
|
||||||
|
<ul>
|
||||||
|
<li class="{% if engage_form.share_target == 'self' or not engage_form.share_target %}is-active{% endif %}">
|
||||||
|
<a onclick="giaEngageSelect('{{ person.id }}', 'share', 'self', this); return false;">Me</a>
|
||||||
|
</li>
|
||||||
|
<li class="{% if engage_form.share_target == 'other' %}is-active{% endif %}">
|
||||||
|
<a onclick="giaEngageSelect('{{ person.id }}', 'share', 'other', this); return false;">Other Party</a>
|
||||||
|
</li>
|
||||||
|
<li class="{% if engage_form.share_target == 'both' %}is-active{% endif %}">
|
||||||
|
<a onclick="giaEngageSelect('{{ person.id }}', 'share', 'both', this); return false;">Both Parties</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Context (optional)</label>
|
||||||
|
<textarea class="textarea is-small" rows="2" name="context_note" placeholder="One additional note for this share.">{{ engage_form.context_note }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons are-small" style="margin-top: 0.15rem;">
|
||||||
|
<button id="engage-send-btn-{{ person.id }}" type="submit" class="button is-link is-light" onclick="giaEngageSetAction('{{ person.id }}', 'send');" {% if not send_state.can_send %}disabled{% endif %}>
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="button is-info is-light" onclick="giaEngageSetAction('{{ person.id }}', 'queue');">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
|
||||||
|
<span>Add To Queue</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if engage_preview %}
|
||||||
|
<article class="box {% if engage_preview_flash %}engage-preview-flash{% endif %}" style="margin-top: 0.6rem; padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Preview</p>
|
||||||
|
<pre style="margin: 0; white-space: pre-wrap; font-size: 0.78rem; line-height: 1.36;">{{ engage_preview }}</pre>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<article class="box" style="margin-top: 0.6rem; padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">No preview yet.</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mitigation-tab-{{ person.id }}-fundamentals" class="mitigation-tab-pane" style="display: none;">
|
||||||
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
|
<div class="column is-12-mobile is-5-tablet" style="padding: 0.35rem;">
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none; height: 100%;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Current Fundamentals</p>
|
||||||
|
{% if plan.fundamental_items %}
|
||||||
|
<div class="content" style="margin-bottom: 0;">
|
||||||
|
<ul style="margin-top: 0;">
|
||||||
|
{% for item in plan.fundamental_items %}
|
||||||
|
<li>{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="is-size-7 has-text-grey">No fundamentals yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="column is-12-mobile is-7-tablet" style="padding: 0.35rem;">
|
||||||
|
<article class="box" style="padding: 0.65rem; 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_fundamentals_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:'fundamentals' }}">
|
||||||
|
<div class="field" style="margin-bottom: 0.45rem;">
|
||||||
|
<label class="label is-small">Edit Fundamentals (one per line)</label>
|
||||||
|
<textarea class="textarea is-small" rows="10" name="fundamentals_text">{{ fundamentals_text }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button class="button is-small is-link is-light">Save Fundamentals</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mitigation-tab-{{ person.id }}-auto" class="mitigation-tab-pane" style="display: none;">
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none; margin-bottom: 0.55rem;">
|
||||||
|
<p class="is-size-7" style="margin-bottom: 0.35rem;">
|
||||||
|
Auto checks read recent message rows and can write linked mitigation objects for this workspace conversation.
|
||||||
|
</p>
|
||||||
|
<p class="is-size-7" style="margin-bottom: 0;">
|
||||||
|
Last run: {% if auto_settings.last_run_at %}{{ auto_settings.last_run_at }}{% else %}Never{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if auto_settings.last_result_summary %}
|
||||||
|
<p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;">{{ auto_settings.last_result_summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_auto' 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="auto">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
<input class="input is-small" type="number" min="10" max="200" name="sample_message_window" value="{{ auto_settings.sample_message_window }}">
|
||||||
|
</div>
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Cooldown seconds between checks</label>
|
||||||
|
<input class="input is-small" type="number" min="0" max="86400" name="check_cooldown_seconds" value="{{ auto_settings.check_cooldown_seconds }}">
|
||||||
|
</div>
|
||||||
|
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">NTFY topic override for auto</label>
|
||||||
|
<input class="input is-small" type="text" name="ntfy_topic_override" value="{{ auto_settings.ntfy_topic_override|default:'' }}" placeholder="Optional topic override">
|
||||||
|
</div>
|
||||||
|
<div class="column is-12" style="padding: 0.3rem;">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">NTFY URL override for auto</label>
|
||||||
|
<input class="input is-small" type="text" name="ntfy_url_override" value="{{ auto_settings.ntfy_url_override|default:'' }}" placeholder="Optional NTFY URL override">
|
||||||
|
</div>
|
||||||
|
<div class="column is-12" style="padding: 0.3rem;">
|
||||||
|
<p class="is-size-7 has-text-grey">If overrides are empty, notifications fall back to Notification Settings topic/url.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons are-small" style="margin-top: 0.2rem;">
|
||||||
|
<button class="button is-link is-light" name="action" value="save">Save Auto Controls</button>
|
||||||
|
<button class="button is-primary is-light" name="action" value="run_now">Run Check Now</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mitigation-tab-{{ person.id }}-ask_ai" class="mitigation-tab-pane" style="display: none;">
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_export' type='widget' person_id=person.id plan_id=plan.id %}"
|
||||||
|
hx-target="#mitigation-shell-{{ person.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
style="margin-bottom: 0.55rem;">
|
||||||
|
<input type="hidden" name="active_tab" value="{{ active_tab|default:'ask_ai' }}">
|
||||||
|
<div class="field is-grouped is-grouped-multiline is-align-items-flex-end" style="margin-bottom: 0; gap: 0.35rem;">
|
||||||
|
<div class="control">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Bundle</label>
|
||||||
|
<div class="select is-small">
|
||||||
|
<select name="artifact_type">
|
||||||
|
<option value="rulebook">Rulebook</option>
|
||||||
|
<option value="rules">Rules</option>
|
||||||
|
<option value="games">Games</option>
|
||||||
|
<option value="corrections">Corrections</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<label class="label is-small" style="margin-bottom: 0.25rem;">Format</label>
|
||||||
|
<div class="select is-small">
|
||||||
|
<select name="export_format">
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-small is-link is-light" style="margin-top: 1.35rem;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-file-export"></i></span>
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if latest_export %}
|
||||||
|
<article class="box" style="padding: 0.55rem; margin-bottom: 0.6rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">
|
||||||
|
Last Export: {{ latest_export.artifact_type|title }} ({{ latest_export.export_format|upper }})
|
||||||
|
</p>
|
||||||
|
<pre style="max-height: 14rem; overflow: auto; margin: 0; white-space: pre-wrap; font-size: 0.72rem; line-height: 1.28;">{{ latest_export.payload }}</pre>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Ask AI</p>
|
||||||
|
|
||||||
|
<div style="max-height: 12rem; overflow-y: auto; margin-bottom: 0.55rem; padding-right: 0.2rem;">
|
||||||
|
{% for message in mitigation_messages %}
|
||||||
|
<div style="margin-bottom: 0.45rem;">
|
||||||
|
<span class="tag is-light is-small">{{ message.role }}</span>
|
||||||
|
<div style="margin-top: 0.15rem; white-space: pre-wrap;">{{ message.text }}</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="is-size-7 has-text-grey">No messages yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-post="{% url 'ai_workspace_mitigation_chat' 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:'ask_ai' }}">
|
||||||
|
<div class="field" style="margin-bottom: 0.5rem;">
|
||||||
|
<div class="control">
|
||||||
|
<textarea name="message" class="textarea is-small" rows="2" placeholder="Refine the plan or request a new lens..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="button is-small is-primary is-light">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-comments"></i></span>
|
||||||
|
<span>Ask AI</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes engagePreviewPulse {
|
||||||
|
0% { background-color: rgba(255, 255, 255, 1); }
|
||||||
|
45% { background-color: rgba(236, 246, 255, 1); }
|
||||||
|
100% { background-color: rgba(255, 255, 255, 1); }
|
||||||
|
}
|
||||||
|
#mitigation-shell-{{ person.id }} .engage-preview-flash {
|
||||||
|
animation: engagePreviewPulse 850ms ease-in-out 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const personId = "{{ person.id }}";
|
||||||
|
const canSend = "{{ send_state.can_send|yesno:'1,0' }}" === "1";
|
||||||
|
function resizeEditableTextareas(root) {
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll('textarea[data-editable="1"]').forEach(function(area) {
|
||||||
|
area.style.height = "auto";
|
||||||
|
area.style.height = Math.max(area.scrollHeight, 72) + "px";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.giaEngageSyncSendOverride = function(pid) {
|
||||||
|
if (pid !== personId) return;
|
||||||
|
const forceInput = document.getElementById("engage-force-send-" + pid);
|
||||||
|
const sendBtn = document.getElementById("engage-send-btn-" + pid);
|
||||||
|
const force =
|
||||||
|
!!(window.giaWorkspaceState
|
||||||
|
&& window.giaWorkspaceState[pid]
|
||||||
|
&& window.giaWorkspaceState[pid].forceSend);
|
||||||
|
if (forceInput) {
|
||||||
|
forceInput.value = force ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (sendBtn) {
|
||||||
|
sendBtn.disabled = !canSend && !force;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setActiveTabHiddenFields(tabName) {
|
||||||
|
const root = document.getElementById("mitigation-shell-" + personId);
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
|
||||||
|
input.value = tabName;
|
||||||
|
});
|
||||||
|
resizeEditableTextareas(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.giaMitigationShowTab = function(pid, tabName) {
|
||||||
|
if (pid !== personId) return;
|
||||||
|
["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"].forEach(function(name) {
|
||||||
|
const pane = document.getElementById("mitigation-tab-" + personId + "-" + name);
|
||||||
|
const tab = document.getElementById("mitigation-tab-btn-" + personId + "-" + name);
|
||||||
|
if (!pane || !tab) return;
|
||||||
|
const active = name === tabName;
|
||||||
|
pane.style.display = active ? "block" : "none";
|
||||||
|
tab.classList.toggle("is-active", active);
|
||||||
|
});
|
||||||
|
setActiveTabHiddenFields(tabName);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaMitigationToggleEdit = function(button) {
|
||||||
|
const form = button.closest("form");
|
||||||
|
if (!form) return;
|
||||||
|
const editing = button.dataset.editState === "edit";
|
||||||
|
const fields = form.querySelectorAll('[data-editable="1"]');
|
||||||
|
if (!editing) {
|
||||||
|
fields.forEach(function(field) {
|
||||||
|
field.removeAttribute("readonly");
|
||||||
|
});
|
||||||
|
button.dataset.editState = "edit";
|
||||||
|
button.textContent = "Save";
|
||||||
|
button.classList.remove("is-light");
|
||||||
|
resizeEditableTextareas(form);
|
||||||
|
} else {
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaEngageSetAction = function(pid, action) {
|
||||||
|
if (pid !== personId) return;
|
||||||
|
const actionInput = document.getElementById("engage-action-input-" + pid);
|
||||||
|
if (actionInput) {
|
||||||
|
actionInput.value = action;
|
||||||
|
}
|
||||||
|
if (action === "send") {
|
||||||
|
window.giaEngageSyncSendOverride(pid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaEngageAutoPreview = function(pid) {
|
||||||
|
if (pid !== personId) return;
|
||||||
|
const form = document.getElementById("engage-form-" + pid);
|
||||||
|
if (!form) return;
|
||||||
|
window.giaEngageSetAction(pid, "preview");
|
||||||
|
form.requestSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaEngageSelect = function(pid, kind, value, node) {
|
||||||
|
if (pid !== personId) return;
|
||||||
|
let inputId = "";
|
||||||
|
if (kind === "share") {
|
||||||
|
inputId = "engage-share-input-" + pid;
|
||||||
|
} else if (kind === "framing") {
|
||||||
|
inputId = "engage-framing-input-" + pid;
|
||||||
|
}
|
||||||
|
const input = inputId ? document.getElementById(inputId) : null;
|
||||||
|
if (input) {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
const li = node && node.closest ? node.closest("li") : null;
|
||||||
|
if (!li) return;
|
||||||
|
const ul = li.parentElement;
|
||||||
|
if (!ul) return;
|
||||||
|
Array.from(ul.children).forEach(function(child) {
|
||||||
|
child.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
li.classList.add("is-active");
|
||||||
|
window.giaEngageAutoPreview(pid);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");
|
||||||
|
resizeEditableTextareas(document.getElementById("mitigation-shell-" + personId));
|
||||||
|
window.giaEngageSyncSendOverride(personId);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
||||||
|
<div class="notification is-{{ level|default:'info' }} is-light" style="padding: 0.55rem 0.7rem; margin: 0;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
698
core/templates/partials/ai-workspace-person-widget.html
Normal file
698
core/templates/partials/ai-workspace-person-widget.html
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
<div
|
||||||
|
class="ai-person-widget"
|
||||||
|
id="ai-person-widget-{{ person.id }}"
|
||||||
|
data-run-url-template="{% url 'ai_workspace_run' type='widget' person_id=person.id operation='summarise' %}"
|
||||||
|
data-send-url="{% url 'ai_workspace_send' type='widget' person_id=person.id %}"
|
||||||
|
data-queue-url="{% url 'ai_workspace_queue' type='widget' person_id=person.id %}"
|
||||||
|
data-limit="{{ limit }}"
|
||||||
|
data-can-send="{{ send_state.can_send|yesno:'1,0' }}">
|
||||||
|
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
|
||||||
|
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
|
||||||
|
<p class="is-size-7">Showing last {{ limit }} messages.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification is-{{ send_state.level }} is-light" style="padding: 0.5rem 0.75rem;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; flex-wrap: wrap;">
|
||||||
|
<div><strong>Send:</strong> {{ send_state.text }}</div>
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
{% if not send_state.can_send %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="draft-override-top-btn-{{ person.id }}"
|
||||||
|
class="button is-warning is-light"
|
||||||
|
onclick="giaWorkspaceEnableSendOverride('{{ person.id }}', 'draft_reply'); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-triangle-exclamation"></i></span>
|
||||||
|
<span>Allow Send In Pane</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="ai-op-form-{{ person.id }}" style="margin-bottom: 0.75rem;">
|
||||||
|
<input type="hidden" name="limit" value="{{ limit }}">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-small">Notes</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea is-small" name="user_notes" rows="2" placeholder="Optional intent/context"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="ai-response-shell-{{ person.id }}" style="display: block; margin-bottom: 0.9rem;">
|
||||||
|
<div class="ai-response-capsule" style="margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.16); border-radius: 8px; padding: 0.5rem 0.6rem;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.4rem;">
|
||||||
|
<div class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
|
||||||
|
<ul>
|
||||||
|
<li id="ai-tab-{{ person.id }}-artifacts">
|
||||||
|
<a onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">Plan</a>
|
||||||
|
</li>
|
||||||
|
<li id="ai-tab-{{ person.id }}-summarise">
|
||||||
|
<a onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">Summary</a>
|
||||||
|
</li>
|
||||||
|
<li id="ai-tab-{{ person.id }}-draft_reply" class="is-active">
|
||||||
|
<a onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">Draft</a>
|
||||||
|
</li>
|
||||||
|
<li id="ai-tab-{{ person.id }}-extract_patterns">
|
||||||
|
<a onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">Patterns</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="is-flex is-align-items-center" style="gap: 0.35rem;">
|
||||||
|
<span id="ai-cache-indicator-{{ person.id }}" class="tag is-warning is-light is-small" style="display: none;">
|
||||||
|
Cached
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-ghost"
|
||||||
|
title="Refresh current tab"
|
||||||
|
onclick="giaWorkspaceRefresh('{{ person.id }}'); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ai-stage-{{ person.id }}" style="min-height: 7rem;">
|
||||||
|
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-warning is-light is-small is-rounded"
|
||||||
|
onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
|
||||||
|
<span>Plan</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-link is-light is-small is-rounded"
|
||||||
|
onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
|
||||||
|
<span>Summary</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-primary is-light is-small is-rounded"
|
||||||
|
onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
|
<span>Draft</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-info is-light is-small is-rounded"
|
||||||
|
onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-wave-square"></i></span>
|
||||||
|
<span>Patterns</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
|
||||||
|
{% if message_rows %}
|
||||||
|
{% for row in message_rows %}
|
||||||
|
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
|
||||||
|
<div class="media-content">
|
||||||
|
<div
|
||||||
|
class="content"
|
||||||
|
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
|
||||||
|
<div
|
||||||
|
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
|
||||||
|
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
|
||||||
|
<p class="is-size-7">
|
||||||
|
{{ row.ts_label }}
|
||||||
|
{% if row.message.custom_author %}
|
||||||
|
| {{ row.message.custom_author }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="has-text-grey">No messages found for this contact.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes aiFadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.ai-animate-in {
|
||||||
|
animation: aiFadeInUp 180ms ease-out;
|
||||||
|
}
|
||||||
|
.ai-response-capsule {
|
||||||
|
transition: all 180ms ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const personId = "{{ person.id }}";
|
||||||
|
const canSend = (document.getElementById("ai-person-widget-" + personId)?.dataset.canSend || "0") === "1";
|
||||||
|
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const widget = document.getElementById("ai-person-widget-" + personId);
|
||||||
|
if (!widget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.giaWorkspaceState = window.giaWorkspaceState || {};
|
||||||
|
window.giaWorkspaceCache = window.giaWorkspaceCache || (function() {
|
||||||
|
try {
|
||||||
|
// One-time migration flush to avoid stale cached pane HTML from earlier UI schema.
|
||||||
|
localStorage.removeItem("gia_workspace_cache_v1");
|
||||||
|
localStorage.removeItem("gia_workspace_cache_v2");
|
||||||
|
return JSON.parse(localStorage.getItem("gia_workspace_cache_v3") || "{}");
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function persistCache() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("gia_workspace_cache_v3", JSON.stringify(window.giaWorkspaceCache));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore storage write issues.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runUrl(operation) {
|
||||||
|
const template = widget.dataset.runUrlTemplate || "";
|
||||||
|
if (template.indexOf("/summarise/") >= 0) {
|
||||||
|
return template.replace("/summarise/", "/" + operation + "/");
|
||||||
|
}
|
||||||
|
return template.replace("summarise", operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formData() {
|
||||||
|
const form = document.getElementById("ai-op-form-" + personId);
|
||||||
|
const params = new URLSearchParams(new FormData(form));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(operation) {
|
||||||
|
return personId + "|" + operation + "|" + formData().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyForceSendState(operation) {
|
||||||
|
const force = !!(window.giaWorkspaceState[personId] && window.giaWorkspaceState[personId].forceSend);
|
||||||
|
const forceInput = document.getElementById("draft-send-force-" + personId + "-" + operation);
|
||||||
|
const sendBtn = document.getElementById("draft-send-btn-" + personId + "-" + operation);
|
||||||
|
if (forceInput) {
|
||||||
|
forceInput.value = force ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (sendBtn && !canSend) {
|
||||||
|
sendBtn.disabled = !force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcLabel(tsMs) {
|
||||||
|
const ts = Number(tsMs || 0);
|
||||||
|
if (!ts) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const dt = new Date(ts);
|
||||||
|
function pad(value) {
|
||||||
|
return String(value).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
dt.getUTCFullYear()
|
||||||
|
+ "-" + pad(dt.getUTCMonth() + 1)
|
||||||
|
+ "-" + pad(dt.getUTCDate())
|
||||||
|
+ " " + pad(dt.getUTCHours())
|
||||||
|
+ ":" + pad(dt.getUTCMinutes())
|
||||||
|
+ " UTC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOutgoingMessage(tsMs, text, author) {
|
||||||
|
const host = document.getElementById("ai-message-list-" + personId);
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const noMessages = host.querySelector("p.has-text-grey");
|
||||||
|
if (noMessages) {
|
||||||
|
noMessages.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = document.createElement("article");
|
||||||
|
article.className = "media ai-message-row";
|
||||||
|
article.dataset.ts = String(Number(tsMs || Date.now()));
|
||||||
|
article.style.marginBottom = "0.75rem";
|
||||||
|
|
||||||
|
const mediaContent = document.createElement("div");
|
||||||
|
mediaContent.className = "media-content";
|
||||||
|
|
||||||
|
const contentWrap = document.createElement("div");
|
||||||
|
contentWrap.className = "content";
|
||||||
|
contentWrap.style.marginLeft = "15%";
|
||||||
|
contentWrap.style.marginRight = "0";
|
||||||
|
|
||||||
|
const bubble = document.createElement("div");
|
||||||
|
bubble.style.marginBottom = "0.25rem";
|
||||||
|
bubble.style.padding = "0.6rem";
|
||||||
|
bubble.style.borderRadius = "6px";
|
||||||
|
bubble.style.border = "1px solid rgba(0, 0, 0, 0.15)";
|
||||||
|
bubble.style.background = "#f0f7ff";
|
||||||
|
bubble.style.boxShadow = "none";
|
||||||
|
|
||||||
|
const bodyP = document.createElement("p");
|
||||||
|
bodyP.style.whiteSpace = "pre-wrap";
|
||||||
|
bodyP.style.marginBottom = "0.35rem";
|
||||||
|
bodyP.textContent = text || "(no text)";
|
||||||
|
|
||||||
|
const metaP = document.createElement("p");
|
||||||
|
metaP.className = "is-size-7";
|
||||||
|
metaP.textContent = formatUtcLabel(tsMs);
|
||||||
|
if (author) {
|
||||||
|
metaP.textContent += " | " + author;
|
||||||
|
}
|
||||||
|
|
||||||
|
bubble.appendChild(bodyP);
|
||||||
|
bubble.appendChild(metaP);
|
||||||
|
contentWrap.appendChild(bubble);
|
||||||
|
mediaContent.appendChild(contentWrap);
|
||||||
|
article.appendChild(mediaContent);
|
||||||
|
host.appendChild(article);
|
||||||
|
|
||||||
|
const maxRows = Math.max(5, Math.min(parseInt(widget.dataset.limit || "20", 10) || 20, 200));
|
||||||
|
const rows = host.querySelectorAll(".ai-message-row");
|
||||||
|
if (rows.length > maxRows) {
|
||||||
|
const removeCount = rows.length - maxRows;
|
||||||
|
for (let i = 0; i < removeCount; i += 1) {
|
||||||
|
if (rows[i] && rows[i].parentNode) {
|
||||||
|
rows[i].parentNode.removeChild(rows[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host.scrollTop = host.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCacheEntry(operation) {
|
||||||
|
const key = cacheKey(operation);
|
||||||
|
const raw = window.giaWorkspaceCache[key];
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function evict() {
|
||||||
|
delete window.giaWorkspaceCache[key];
|
||||||
|
persistCache();
|
||||||
|
}
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
// Backward compatibility: old format has no timestamp; treat as expired.
|
||||||
|
evict();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === "object" && typeof raw.html === "string") {
|
||||||
|
const ts = typeof raw.ts === "number" ? raw.ts : null;
|
||||||
|
if (!ts) {
|
||||||
|
evict();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((Date.now() - ts) > CACHE_TTL_MS) {
|
||||||
|
evict();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { html: raw.html, ts: ts };
|
||||||
|
}
|
||||||
|
evict();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCacheAge(ts) {
|
||||||
|
if (!ts) {
|
||||||
|
return "Cached";
|
||||||
|
}
|
||||||
|
const deltaSec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||||
|
if (deltaSec < 5) return "Cached just now";
|
||||||
|
if (deltaSec < 60) return "Cached " + deltaSec + "s ago";
|
||||||
|
if (deltaSec < 3600) return "Cached " + Math.floor(deltaSec / 60) + "m ago";
|
||||||
|
if (deltaSec < 86400) return "Cached " + Math.floor(deltaSec / 3600) + "h ago";
|
||||||
|
return "Cached " + Math.floor(deltaSec / 86400) + "d ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeInlineScripts(container) {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scripts = container.querySelectorAll("script");
|
||||||
|
scripts.forEach(function(oldScript) {
|
||||||
|
const newScript = document.createElement("script");
|
||||||
|
if (oldScript.src) {
|
||||||
|
newScript.src = oldScript.src;
|
||||||
|
} else {
|
||||||
|
newScript.textContent = oldScript.textContent || "";
|
||||||
|
}
|
||||||
|
Array.from(oldScript.attributes || []).forEach(function(attr) {
|
||||||
|
if (attr.name !== "src") {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedIndicator(show, ts) {
|
||||||
|
const indicator = document.getElementById("ai-cache-indicator-" + personId);
|
||||||
|
if (!indicator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (show) {
|
||||||
|
indicator.textContent = formatCacheAge(ts);
|
||||||
|
}
|
||||||
|
indicator.style.display = show ? "inline-flex" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateCachedIfAvailable(operation) {
|
||||||
|
if (operation === "artifacts") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entry = getCacheEntry(operation);
|
||||||
|
const pane = document.getElementById("ai-pane-" + personId + "-" + operation);
|
||||||
|
if (!pane) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (entry && !pane.dataset.loaded) {
|
||||||
|
pane.innerHTML = entry.html;
|
||||||
|
pane.dataset.loaded = "1";
|
||||||
|
executeInlineScripts(pane);
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.process(pane);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.giaWorkspaceShowTab = function(pid, operation) {
|
||||||
|
if (pid !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
["artifacts", "summarise", "draft_reply", "extract_patterns"].forEach(function(op) {
|
||||||
|
const tab = document.getElementById("ai-tab-" + personId + "-" + op);
|
||||||
|
const pane = document.getElementById("ai-pane-" + personId + "-" + op);
|
||||||
|
if (!tab || !pane) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (op === operation) {
|
||||||
|
tab.classList.add("is-active");
|
||||||
|
pane.style.display = "block";
|
||||||
|
} else {
|
||||||
|
tab.classList.remove("is-active");
|
||||||
|
pane.style.display = "none";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const hydrated = hydrateCachedIfAvailable(operation);
|
||||||
|
const entry = operation === "artifacts" ? null : getCacheEntry(operation);
|
||||||
|
setCachedIndicator(hydrated || !!entry, entry ? entry.ts : null);
|
||||||
|
window.giaWorkspaceState[personId] = window.giaWorkspaceState[personId] || {};
|
||||||
|
window.giaWorkspaceState[personId].current = operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaWorkspaceRun = function(pid, operation, forceRefresh) {
|
||||||
|
if (pid !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cacheAllowed = operation !== "artifacts";
|
||||||
|
const shell = document.getElementById("ai-response-shell-" + personId);
|
||||||
|
const pane = document.getElementById("ai-pane-" + personId + "-" + operation);
|
||||||
|
if (!shell || !pane) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentState = window.giaWorkspaceState[personId] || {};
|
||||||
|
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
|
||||||
|
window.giaWorkspaceShowTab(personId, operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.giaWorkspaceShowTab(personId, operation);
|
||||||
|
|
||||||
|
const key = cacheKey(operation);
|
||||||
|
const entry = getCacheEntry(operation);
|
||||||
|
if (cacheAllowed && !forceRefresh && entry) {
|
||||||
|
pane.innerHTML = entry.html;
|
||||||
|
pane.dataset.loaded = "1";
|
||||||
|
pane.classList.remove("ai-animate-in");
|
||||||
|
void pane.offsetWidth;
|
||||||
|
pane.classList.add("ai-animate-in");
|
||||||
|
setCachedIndicator(true, entry.ts);
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.process(pane);
|
||||||
|
}
|
||||||
|
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
|
||||||
|
window.giaWorkspaceUseDraft(personId, operation, 0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedIndicator(false, null);
|
||||||
|
pane.innerHTML = '<div class="notification is-light ai-animate-in">Loading...</div>';
|
||||||
|
const url = runUrl(operation) + "?" + formData().toString();
|
||||||
|
fetch(url, { method: "GET" })
|
||||||
|
.then(function(resp) { return resp.text(); })
|
||||||
|
.then(function(html) {
|
||||||
|
pane.innerHTML = html;
|
||||||
|
pane.dataset.loaded = "1";
|
||||||
|
executeInlineScripts(pane);
|
||||||
|
pane.classList.remove("ai-animate-in");
|
||||||
|
void pane.offsetWidth;
|
||||||
|
pane.classList.add("ai-animate-in");
|
||||||
|
if (cacheAllowed) {
|
||||||
|
window.giaWorkspaceCache[key] = {
|
||||||
|
html: html,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
persistCache();
|
||||||
|
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
|
||||||
|
} else {
|
||||||
|
setCachedIndicator(false, null);
|
||||||
|
}
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.process(pane);
|
||||||
|
}
|
||||||
|
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
|
||||||
|
window.giaWorkspaceUseDraft(personId, operation, 0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaWorkspaceRefresh = function(pid) {
|
||||||
|
if (pid !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = (window.giaWorkspaceState[personId] && window.giaWorkspaceState[personId].current) || "summarise";
|
||||||
|
window.giaWorkspaceRun(personId, current, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaWorkspaceUseDraft = function(pid, operation, index) {
|
||||||
|
if (pid !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const host = document.getElementById("draft-host-" + personId + "-" + operation);
|
||||||
|
const optionCard = host ? host.querySelector('.draft-option-card[data-index="' + index + '"]') : null;
|
||||||
|
const option = optionCard ? optionCard.querySelector(".draft-text") : null;
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cards = host ? host.querySelectorAll(".draft-option-card") : [];
|
||||||
|
cards.forEach(function(el) { el.classList.remove("is-selected"); });
|
||||||
|
if (optionCard) {
|
||||||
|
optionCard.classList.add("is-selected");
|
||||||
|
}
|
||||||
|
host.dataset.selected = String(index);
|
||||||
|
const sendShell = document.getElementById("draft-send-shell-" + personId + "-" + operation);
|
||||||
|
const hiddenInput = document.getElementById("draft-send-input-" + personId + "-" + operation);
|
||||||
|
const preview = document.getElementById("draft-send-preview-" + personId + "-" + operation);
|
||||||
|
if (!sendShell || !hiddenInput || !preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hiddenInput.value = option.textContent.trim();
|
||||||
|
preview.value = option.textContent.trim();
|
||||||
|
applyForceSendState(operation);
|
||||||
|
sendShell.classList.remove("ai-animate-in");
|
||||||
|
void sendShell.offsetWidth;
|
||||||
|
sendShell.classList.add("ai-animate-in");
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaWorkspaceEnableSendOverride = function(pid, operation) {
|
||||||
|
if (pid !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.giaWorkspaceState[personId] = window.giaWorkspaceState[personId] || {};
|
||||||
|
window.giaWorkspaceState[personId].forceSend = true;
|
||||||
|
applyForceSendState(operation);
|
||||||
|
if (typeof window.giaEngageSyncSendOverride === "function") {
|
||||||
|
window.giaEngageSyncSendOverride(personId);
|
||||||
|
}
|
||||||
|
const overrideBtn = document.getElementById("draft-override-top-btn-" + personId);
|
||||||
|
if (overrideBtn) {
|
||||||
|
overrideBtn.classList.remove("is-warning");
|
||||||
|
overrideBtn.classList.add("is-success");
|
||||||
|
const labelNode = overrideBtn.querySelector("span:last-child");
|
||||||
|
if (labelNode) {
|
||||||
|
labelNode.textContent = "Override Enabled";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusHost = document.getElementById("draft-top-status-" + personId);
|
||||||
|
if (statusHost) {
|
||||||
|
statusHost.innerHTML = '<div class="notification is-success is-light" style="padding: 0.45rem 0.6rem;">Send override enabled for this pane.</div>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.giaWorkspaceQueueSelectedDraft = function(pid) {
|
||||||
|
if (pid !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queueUrl = widget.dataset.queueUrl;
|
||||||
|
const preview = document.getElementById("draft-send-preview-" + personId + "-draft_reply");
|
||||||
|
const statusHost = document.getElementById("draft-top-status-" + personId);
|
||||||
|
const text = preview ? preview.value.trim() : "";
|
||||||
|
if (!text) {
|
||||||
|
if (statusHost) {
|
||||||
|
statusHost.innerHTML = '<div class="notification is-warning is-light" style="padding: 0.45rem 0.6rem;">Select a draft first, then queue it.</div>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = new URLSearchParams();
|
||||||
|
payload.append("draft_text", text);
|
||||||
|
fetch(queueUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-CSRFToken": "{{ csrf_token }}",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
})
|
||||||
|
.then(function(resp) { return resp.text(); })
|
||||||
|
.then(function(html) {
|
||||||
|
if (statusHost) {
|
||||||
|
statusHost.innerHTML = html;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (statusHost) {
|
||||||
|
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window.giaMitigationShowTab !== "function") {
|
||||||
|
window.giaMitigationShowTab = function(pid, tabName) {
|
||||||
|
const names = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"];
|
||||||
|
names.forEach(function(name) {
|
||||||
|
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
|
||||||
|
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
|
||||||
|
if (!pane || !tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = (name === tabName);
|
||||||
|
pane.style.display = active ? "block" : "none";
|
||||||
|
tab.classList.toggle("is-active", active);
|
||||||
|
});
|
||||||
|
const shell = document.getElementById("mitigation-shell-" + pid);
|
||||||
|
if (!shell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
|
||||||
|
input.value = tabName;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.giaMitigationToggleEdit !== "function") {
|
||||||
|
window.giaMitigationToggleEdit = function(button) {
|
||||||
|
const form = button ? button.closest("form") : null;
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const editing = button.dataset.editState === "edit";
|
||||||
|
const fields = form.querySelectorAll('[data-editable="1"]');
|
||||||
|
if (!editing) {
|
||||||
|
fields.forEach(function(field) {
|
||||||
|
field.removeAttribute("readonly");
|
||||||
|
});
|
||||||
|
button.dataset.editState = "edit";
|
||||||
|
button.textContent = "Save";
|
||||||
|
button.classList.remove("is-light");
|
||||||
|
} else {
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.giaEngageSetAction !== "function") {
|
||||||
|
window.giaEngageSetAction = function(pid, action) {
|
||||||
|
const actionInput = document.getElementById("engage-action-input-" + pid);
|
||||||
|
if (actionInput) {
|
||||||
|
actionInput.value = action;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.giaEngageAutoPreview !== "function") {
|
||||||
|
window.giaEngageAutoPreview = function(pid) {
|
||||||
|
const form = document.getElementById("engage-form-" + pid);
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.giaEngageSetAction(pid, "preview");
|
||||||
|
form.requestSubmit();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.giaEngageSelect !== "function") {
|
||||||
|
window.giaEngageSelect = function(pid, kind, value, node) {
|
||||||
|
let inputId = "";
|
||||||
|
if (kind === "share") {
|
||||||
|
inputId = "engage-share-input-" + pid;
|
||||||
|
} else if (kind === "framing") {
|
||||||
|
inputId = "engage-framing-input-" + pid;
|
||||||
|
}
|
||||||
|
const input = inputId ? document.getElementById(inputId) : null;
|
||||||
|
if (input) {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
const li = node && node.closest ? node.closest("li") : null;
|
||||||
|
if (li && li.parentElement) {
|
||||||
|
Array.from(li.parentElement.children).forEach(function(child) {
|
||||||
|
child.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
li.classList.add("is-active");
|
||||||
|
}
|
||||||
|
window.giaEngageAutoPreview(pid);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.giaWorkspaceMessageListeners = window.giaWorkspaceMessageListeners || {};
|
||||||
|
const existingListener = window.giaWorkspaceMessageListeners[personId];
|
||||||
|
if (existingListener) {
|
||||||
|
document.body.removeEventListener("gia-message-sent", existingListener);
|
||||||
|
}
|
||||||
|
const messageSentListener = function(evt) {
|
||||||
|
const detail = (evt && evt.detail) ? evt.detail : {};
|
||||||
|
if (!detail || String(detail.person_id || "") !== personId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendOutgoingMessage(
|
||||||
|
Number(detail.ts || Date.now()),
|
||||||
|
String(detail.text || ""),
|
||||||
|
String(detail.author || "BOT")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
document.body.addEventListener("gia-message-sent", messageSentListener);
|
||||||
|
window.giaWorkspaceMessageListeners[personId] = messageSentListener;
|
||||||
|
|
||||||
|
window.giaWorkspaceRun(personId, "artifacts", false);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
3
core/templates/partials/ai-workspace-send-status.html
Normal file
3
core/templates/partials/ai-workspace-send-status.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="notification is-{{ level }} is-light" style="padding: 0.55rem 0.75rem;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
54
core/templates/partials/ai-workspace-widget.html
Normal file
54
core/templates/partials/ai-workspace-widget.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<div class="ai-workspace-widget">
|
||||||
|
<div class="columns is-mobile is-gapless">
|
||||||
|
<div class="column is-12-mobile is-12-tablet">
|
||||||
|
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
|
||||||
|
<p class="is-size-7 has-text-weight-semibold">AI Workspace</p>
|
||||||
|
<h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3>
|
||||||
|
<p class="is-size-7">
|
||||||
|
Pick a person to open their message timeline in a fresh pane.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="ai-window-form" style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
|
||||||
|
<label class="label is-small" for="id_limit">Window</label>
|
||||||
|
<div class="select is-fullwidth is-small">
|
||||||
|
{{ window_form.limit }}
|
||||||
|
</div>
|
||||||
|
<p class="help">{{ window_form.limit.help_text }}</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% if contact_rows %}
|
||||||
|
<div class="buttons are-small" style="display: grid; gap: 0.5rem;">
|
||||||
|
{% for row in contact_rows %}
|
||||||
|
<button
|
||||||
|
class="button is-fullwidth"
|
||||||
|
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
|
||||||
|
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
|
||||||
|
hx-include="#ai-window-form"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="afterend">
|
||||||
|
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
|
||||||
|
<span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;">
|
||||||
|
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
|
||||||
|
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
|
||||||
|
<strong>{{ row.person.name }}</strong>
|
||||||
|
</span>
|
||||||
|
{% if row.last_ts_label %}
|
||||||
|
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="tag is-dark" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="has-text-grey">No contacts available yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -21,11 +21,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.user }}</td>
|
<td>{{ item.user }}</td>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.name }}</td>
|
<td>{{ item.name }}</td>
|
||||||
|
|||||||
@@ -22,24 +22,24 @@
|
|||||||
{% for item in object_list %}
|
{% for item in object_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.session }}</td>
|
<td>{{ item.session }}</td>
|
||||||
<td>{{ item.ts }}</td>
|
<td>{{ item.ts }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.sender_uuid }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.sender_uuid }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.text }}</td>
|
<td>{{ item.text }}</td>
|
||||||
<td>{{ item.custom_author }}</td>
|
<td>{{ item.custom_author }}</td>
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.name }}</td>
|
<td>{{ item.name }}</td>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.alias }}</td>
|
<td>{{ item.alias }}</td>
|
||||||
|
|||||||
@@ -3,69 +3,93 @@
|
|||||||
{% get_last_invalidation 'core.QueuedMessage' as last %}
|
{% get_last_invalidation 'core.QueuedMessage' as last %}
|
||||||
{% include 'mixins/partials/notify.html' %}
|
{% include 'mixins/partials/notify.html' %}
|
||||||
{% cache 600 objects_queue request.user.id object_list type last %}
|
{% cache 600 objects_queue request.user.id object_list type last %}
|
||||||
<table
|
<div
|
||||||
class="table is-fullwidth is-hoverable"
|
|
||||||
hx-target="#{{ context_object_name }}-table"
|
|
||||||
id="{{ context_object_name }}-table"
|
id="{{ context_object_name }}-table"
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||||
hx-get="{{ list_url }}">
|
hx-get="{{ list_url }}"
|
||||||
<thead>
|
hx-target="#{{ context_object_name }}-table"
|
||||||
<th>id</th>
|
hx-swap="outerHTML">
|
||||||
<th>session</th>
|
|
||||||
<th>manipulation</th>
|
|
||||||
<th>ts</th>
|
|
||||||
<th>text</th>
|
|
||||||
<th>actions</th>
|
|
||||||
</thead>
|
|
||||||
{% for item in object_list %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a
|
|
||||||
class="has-text-grey button nowrap-child"
|
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ item.session }}</td>
|
|
||||||
<td>{{ item.manipulation }}</td>
|
|
||||||
<td>{{ item.ts }}</td>
|
|
||||||
<td>{{ item.text.length }}</td>
|
|
||||||
<td>
|
|
||||||
<div class="buttons">
|
|
||||||
<button
|
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
|
||||||
hx-get="{% url 'queue_update' type=type pk=item.id %}"
|
|
||||||
hx-trigger="click"
|
|
||||||
hx-target="#{{ type }}s-here"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="button">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fa-solid fa-pencil"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
|
||||||
hx-delete="{% url 'queue_delete' type=type pk=item.id %}"
|
|
||||||
hx-trigger="click"
|
|
||||||
hx-target="#modals-here"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-confirm="Are you sure you wish to delete {{ item.id }}?"
|
|
||||||
class="button">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</table>
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.75rem; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
{% endcache %}
|
<div>
|
||||||
|
<h3 class="title is-6" style="margin-bottom: 0.15rem;">Outgoing Queue</h3>
|
||||||
|
<p class="is-size-7">Review queued drafts and approve or reject each message.</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag is-dark is-medium">{{ object_list|length }} pending</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if object_list %}
|
||||||
|
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
|
||||||
|
{% for item in object_list %}
|
||||||
|
<div class="column is-12" style="padding: 0.35rem;" id="queue-card-{{ item.id }}">
|
||||||
|
<article class="box" style="padding: 0.75rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-start" style="gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">{{ item.session.identifier.person.name }}</p>
|
||||||
|
<div class="tags" style="margin-bottom: 0.2rem;">
|
||||||
|
<span class="tag is-light">{{ item.session.identifier.service|title }}</span>
|
||||||
|
<span class="tag is-light">{{ item.manipulation.name }}</span>
|
||||||
|
<span class="tag is-light">{{ item.ts }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button
|
||||||
|
class="button is-success is-light"
|
||||||
|
hx-get="{% url 'message_accept_api' message_id=item.id %}"
|
||||||
|
hx-swap="none"
|
||||||
|
_="on htmx:afterRequest if event.detail.successful remove #queue-card-{{ item.id }} then trigger {{ context_object_name_singular }}Event on body end">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-danger is-light"
|
||||||
|
hx-get="{% url 'message_reject_api' message_id=item.id %}"
|
||||||
|
hx-swap="none"
|
||||||
|
_="on htmx:afterRequest if event.detail.successful remove #queue-card-{{ item.id }} then trigger {{ context_object_name_singular }}Event on body end">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-xmark"></i></span>
|
||||||
|
<span>Reject</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 0.6rem; border-radius: 8px; border: 1px solid rgba(0, 0, 0, 0.12); background: rgba(255, 255, 255, 0.45); margin-bottom: 0.5rem;">
|
||||||
|
<p style="white-space: pre-wrap; margin: 0;">{{ item.text|default:"(empty draft)" }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<small class="has-text-grey">Queue ID: {{ item.id }}</small>
|
||||||
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{% url 'queue_update' type=type pk=item.id %}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#{{ type }}s-here"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="button is-light">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-delete="{% url 'queue_delete' type=type pk=item.id %}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#modals-here"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Delete queued message {{ item.id }}?"
|
||||||
|
class="button is-light">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-trash"></i></span>
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<article class="box" style="padding: 0.8rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
|
||||||
|
<p class="is-size-7 has-text-grey">Queue is empty.</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endcache %}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@
|
|||||||
{% for item in object_list %}
|
{% for item in object_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.identifier }}</td>
|
<td>{{ item.identifier }}</td>
|
||||||
<td>{{ item.last_interaction }}</td>
|
<td>{{ item.last_interaction }}</td>
|
||||||
|
|||||||
@@ -85,38 +85,38 @@
|
|||||||
|
|
||||||
</table>
|
</table>
|
||||||
<form
|
<form
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-post="{% url 'signal_account_add' type=type %}"
|
hx-post="{% url 'signal_account_add' type=type %}"
|
||||||
hx-target="#modals-here"
|
hx-target="#modals-here"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div id="device" class="control is-expanded has-icons-left">
|
<div id="device" class="control is-expanded has-icons-left">
|
||||||
<input
|
<input
|
||||||
hx-post="{% url 'signal_account_add' type=type %}"
|
|
||||||
hx-target="#widgets-here"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
name="device"
|
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Account name">
|
|
||||||
<span class="icon is-small is-left">
|
|
||||||
<i class="fa-solid fa-plus"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<button
|
|
||||||
id="search"
|
|
||||||
class="button is-fullwidth"
|
|
||||||
hx-post="{% url 'signal_account_add' type=type %}"
|
hx-post="{% url 'signal_account_add' type=type %}"
|
||||||
hx-trigger="click"
|
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML"
|
||||||
Add account
|
name="device"
|
||||||
</button>
|
class="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Account name">
|
||||||
|
<span class="icon is-small is-left">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<div class="field">
|
||||||
|
<button
|
||||||
|
id="search"
|
||||||
|
class="button is-fullwidth"
|
||||||
|
hx-post="{% url 'signal_account_add' type=type %}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Add account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
@@ -19,14 +19,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.source_number }}</td>
|
<td>{{ item.source_number }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.source_uuid }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.source_uuid }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.account }}</td>
|
<td>{{ item.account }}</td>
|
||||||
<td>{{ item.source_name }}</td>
|
<td>{{ item.source_name }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -7,52 +7,52 @@
|
|||||||
|
|
||||||
{% if object_list is not None %}
|
{% if object_list is not None %}
|
||||||
<table
|
<table
|
||||||
class="table is-fullwidth is-hoverable">
|
class="table is-fullwidth is-hoverable">
|
||||||
<thead>
|
<thead>
|
||||||
<th>name</th>
|
<th>name</th>
|
||||||
<th>number</th>
|
<th>number</th>
|
||||||
<th>uuid</th>
|
<th>uuid</th>
|
||||||
<th>verified</th>
|
<th>verified</th>
|
||||||
<th>blocked</th>
|
<th>blocked</th>
|
||||||
</thead>
|
</thead>
|
||||||
{% for item in object_list.contacts %}
|
{% for item in object_list.contacts %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ item.name }}</td>
|
<td>{{ item.name }}</td>
|
||||||
<td>{{ item.number }}</td>
|
<td>{{ item.number }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="has-text-grey button nowrap-child"
|
class="has-text-grey button nowrap-child"
|
||||||
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.uuid }}');">
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.uuid }}');">
|
||||||
<span class="icon" data-tooltip="Copy to clipboard">
|
<span class="icon" data-tooltip="Copy to clipboard">
|
||||||
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
<i class="fa-solid fa-copy" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.identity.status == "TRUSTED_VERIFIED" %}
|
{% if item.identity.status == "TRUSTED_VERIFIED" %}
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-check"></i>
|
<i class="fa-solid fa-check"></i>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.blocked %}
|
{% if item.blocked %}
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-check"></i>
|
<i class="fa-solid fa-check"></i>
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.forms import AIForm
|
from core.forms import AIForm
|
||||||
from core.models import AI
|
from core.models import AI
|
||||||
@@ -13,11 +7,12 @@ from core.util import logs
|
|||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AIList(LoginRequiredMixin, ObjectList):
|
class AIList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/ai-list.html"
|
list_template = "partials/ai-list.html"
|
||||||
model = AI
|
model = AI
|
||||||
page_title = "AIs"
|
page_title = "AIs"
|
||||||
#page_subtitle = "Add times here in order to permit trading."
|
# page_subtitle = "Add times here in order to permit trading."
|
||||||
|
|
||||||
list_url_name = "ais"
|
list_url_name = "ais"
|
||||||
list_url_args = ["type"]
|
list_url_args = ["type"]
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.forms import GroupForm
|
from core.forms import GroupForm
|
||||||
from core.models import Group
|
from core.models import Group
|
||||||
@@ -13,6 +7,7 @@ from core.util import logs
|
|||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GroupList(LoginRequiredMixin, ObjectList):
|
class GroupList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/group-list.html"
|
list_template = "partials/group-list.html"
|
||||||
model = Group
|
model = Group
|
||||||
@@ -39,4 +34,4 @@ class GroupUpdate(LoginRequiredMixin, ObjectUpdate):
|
|||||||
|
|
||||||
|
|
||||||
class GroupDelete(LoginRequiredMixin, ObjectDelete):
|
class GroupDelete(LoginRequiredMixin, ObjectDelete):
|
||||||
model = Group
|
model = Group
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
|
|
||||||
from core.forms import PersonIdentifierForm
|
from core.forms import PersonIdentifierForm
|
||||||
from core.models import PersonIdentifier, Person
|
from core.models import Person, PersonIdentifier
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IdentifierPermissionMixin:
|
class IdentifierPermissionMixin:
|
||||||
def set_extra_args(self, user):
|
def set_extra_args(self, user):
|
||||||
self.extra_permission_args = {
|
self.extra_permission_args = {
|
||||||
@@ -14,6 +16,7 @@ class IdentifierPermissionMixin:
|
|||||||
"person__pk": self.kwargs["person"],
|
"person__pk": self.kwargs["person"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class PersonIdentifierList(LoginRequiredMixin, IdentifierPermissionMixin, ObjectList):
|
class PersonIdentifierList(LoginRequiredMixin, IdentifierPermissionMixin, ObjectList):
|
||||||
list_template = "partials/identifier-list.html"
|
list_template = "partials/identifier-list.html"
|
||||||
model = PersonIdentifier
|
model = PersonIdentifier
|
||||||
@@ -26,7 +29,9 @@ class PersonIdentifierList(LoginRequiredMixin, IdentifierPermissionMixin, Object
|
|||||||
submit_url_args = ["type", "person"]
|
submit_url_args = ["type", "person"]
|
||||||
|
|
||||||
|
|
||||||
class PersonIdentifierCreate(LoginRequiredMixin, IdentifierPermissionMixin, ObjectCreate):
|
class PersonIdentifierCreate(
|
||||||
|
LoginRequiredMixin, IdentifierPermissionMixin, ObjectCreate
|
||||||
|
):
|
||||||
model = PersonIdentifier
|
model = PersonIdentifier
|
||||||
form_class = PersonIdentifierForm
|
form_class = PersonIdentifierForm
|
||||||
|
|
||||||
@@ -52,7 +57,10 @@ class PersonIdentifierCreate(LoginRequiredMixin, IdentifierPermissionMixin, Obje
|
|||||||
log.error(f"Person {self.kwargs['person']} does not exist")
|
log.error(f"Person {self.kwargs['person']} does not exist")
|
||||||
raise AbortSave("person does not exist or you don't have access")
|
raise AbortSave("person does not exist or you don't have access")
|
||||||
|
|
||||||
class PersonIdentifierUpdate(LoginRequiredMixin, IdentifierPermissionMixin, ObjectUpdate):
|
|
||||||
|
class PersonIdentifierUpdate(
|
||||||
|
LoginRequiredMixin, IdentifierPermissionMixin, ObjectUpdate
|
||||||
|
):
|
||||||
model = PersonIdentifier
|
model = PersonIdentifier
|
||||||
form_class = PersonIdentifierForm
|
form_class = PersonIdentifierForm
|
||||||
|
|
||||||
@@ -60,5 +68,7 @@ class PersonIdentifierUpdate(LoginRequiredMixin, IdentifierPermissionMixin, Obje
|
|||||||
submit_url_args = ["type", "pk", "person"]
|
submit_url_args = ["type", "pk", "person"]
|
||||||
|
|
||||||
|
|
||||||
class PersonIdentifierDelete(LoginRequiredMixin, IdentifierPermissionMixin, ObjectDelete):
|
class PersonIdentifierDelete(
|
||||||
model = PersonIdentifier
|
LoginRequiredMixin, IdentifierPermissionMixin, ObjectDelete
|
||||||
|
):
|
||||||
|
model = PersonIdentifier
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.forms import ManipulationForm
|
from core.forms import ManipulationForm
|
||||||
from core.models import Manipulation
|
from core.models import Manipulation
|
||||||
@@ -13,6 +7,7 @@ from core.util import logs
|
|||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ManipulationList(LoginRequiredMixin, ObjectList):
|
class ManipulationList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/manipulation-list.html"
|
list_template = "partials/manipulation-list.html"
|
||||||
model = Manipulation
|
model = Manipulation
|
||||||
@@ -39,4 +34,4 @@ class ManipulationUpdate(LoginRequiredMixin, ObjectUpdate):
|
|||||||
|
|
||||||
|
|
||||||
class ManipulationDelete(LoginRequiredMixin, ObjectDelete):
|
class ManipulationDelete(LoginRequiredMixin, ObjectDelete):
|
||||||
model = Manipulation
|
model = Manipulation
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
|
|
||||||
from core.forms import MessageForm
|
from core.forms import MessageForm
|
||||||
from core.models import Message
|
from core.models import Message
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MessagePermissionMixin:
|
class MessagePermissionMixin:
|
||||||
def set_extra_args(self, user):
|
def set_extra_args(self, user):
|
||||||
self.extra_permission_args = {
|
self.extra_permission_args = {
|
||||||
@@ -14,6 +16,7 @@ class MessagePermissionMixin:
|
|||||||
"session__pk": self.kwargs["session"],
|
"session__pk": self.kwargs["session"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MessageList(LoginRequiredMixin, MessagePermissionMixin, ObjectList):
|
class MessageList(LoginRequiredMixin, MessagePermissionMixin, ObjectList):
|
||||||
list_template = "partials/message-list.html"
|
list_template = "partials/message-list.html"
|
||||||
model = Message
|
model = Message
|
||||||
@@ -52,6 +55,7 @@ class MessageCreate(LoginRequiredMixin, MessagePermissionMixin, ObjectCreate):
|
|||||||
log.error(f"Session {self.kwargs['session']} does not exist")
|
log.error(f"Session {self.kwargs['session']} does not exist")
|
||||||
raise AbortSave("session does not exist or you don't have access")
|
raise AbortSave("session does not exist or you don't have access")
|
||||||
|
|
||||||
|
|
||||||
class MessageUpdate(LoginRequiredMixin, MessagePermissionMixin, ObjectUpdate):
|
class MessageUpdate(LoginRequiredMixin, MessagePermissionMixin, ObjectUpdate):
|
||||||
model = Message
|
model = Message
|
||||||
form_class = MessageForm
|
form_class = MessageForm
|
||||||
@@ -62,5 +66,3 @@ class MessageUpdate(LoginRequiredMixin, MessagePermissionMixin, ObjectUpdate):
|
|||||||
|
|
||||||
class MessageDelete(LoginRequiredMixin, MessagePermissionMixin, ObjectDelete):
|
class MessageDelete(LoginRequiredMixin, MessagePermissionMixin, ObjectDelete):
|
||||||
model = Message
|
model = Message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.forms import PersonForm
|
from core.forms import PersonForm
|
||||||
from core.models import Person
|
from core.models import Person
|
||||||
@@ -13,11 +7,12 @@ from core.util import logs
|
|||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PersonList(LoginRequiredMixin, ObjectList):
|
class PersonList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/person-list.html"
|
list_template = "partials/person-list.html"
|
||||||
model = Person
|
model = Person
|
||||||
page_title = "People"
|
page_title = "People"
|
||||||
#page_subtitle = "Add times here in order to permit trading."
|
# page_subtitle = "Add times here in order to permit trading."
|
||||||
|
|
||||||
list_url_name = "people"
|
list_url_name = "people"
|
||||||
list_url_args = ["type"]
|
list_url_args = ["type"]
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.forms import PersonaForm
|
from core.forms import PersonaForm
|
||||||
from core.models import Persona
|
from core.models import Persona
|
||||||
@@ -13,6 +7,7 @@ from core.util import logs
|
|||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PersonaList(LoginRequiredMixin, ObjectList):
|
class PersonaList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/persona-list.html"
|
list_template = "partials/persona-list.html"
|
||||||
model = Persona
|
model = Persona
|
||||||
@@ -39,4 +34,4 @@ class PersonaUpdate(LoginRequiredMixin, ObjectUpdate):
|
|||||||
|
|
||||||
|
|
||||||
class PersonaDelete(LoginRequiredMixin, ObjectDelete):
|
class PersonaDelete(LoginRequiredMixin, ObjectDelete):
|
||||||
model = Persona
|
model = Persona
|
||||||
|
|||||||
@@ -1,56 +1,63 @@
|
|||||||
from rest_framework.views import APIView
|
from asgiref.sync import async_to_sync
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db import transaction
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from core.models import QueuedMessage, Message
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from core.clients import signalapi
|
||||||
from core.forms import QueueForm
|
from core.forms import QueueForm
|
||||||
|
from core.models import Message, QueuedMessage
|
||||||
|
from core.util import logs
|
||||||
|
|
||||||
import requests
|
log = logs.get_logger("queue")
|
||||||
import orjson
|
|
||||||
from django.conf import settings
|
|
||||||
import redis
|
|
||||||
import msgpack
|
|
||||||
|
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
# def start_typing(uuid):
|
|
||||||
# url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
|
|
||||||
# data = {
|
|
||||||
# "recipient": uuid,
|
|
||||||
# }
|
|
||||||
|
|
||||||
# response = requests.put(url, json=data)
|
|
||||||
|
|
||||||
# def stop_typing(uuid):
|
|
||||||
# url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
|
|
||||||
# data = {
|
|
||||||
# "recipient": uuid,
|
|
||||||
# }
|
|
||||||
|
|
||||||
# response = requests.delete(url, json=data)
|
|
||||||
|
|
||||||
r = redis.from_url("unix://var/run/gia-redis.sock", db=10)
|
|
||||||
|
|
||||||
|
|
||||||
class AcceptMessageAPI(LoginRequiredMixin, APIView):
|
class AcceptMessageAPI(LoginRequiredMixin, APIView):
|
||||||
def get(self, request, message_id):
|
def get(self, request, message_id):
|
||||||
to_submit = {
|
try:
|
||||||
"type": "def",
|
queued = QueuedMessage.objects.select_related(
|
||||||
"method": "accept_message",
|
"session",
|
||||||
"user_id": request.user.id,
|
"session__identifier",
|
||||||
"message_id": message_id,
|
"session__user",
|
||||||
}
|
).get(
|
||||||
packed = msgpack.packb(to_submit, use_bin_type=True)
|
user=request.user,
|
||||||
r.publish("processing", packed)
|
id=message_id,
|
||||||
|
)
|
||||||
|
except QueuedMessage.DoesNotExist:
|
||||||
|
return HttpResponse(status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if queued.session.identifier.service != "signal":
|
||||||
|
log.warning(
|
||||||
|
"Queue accept failed: unsupported service '%s' for queued message %s",
|
||||||
|
queued.session.identifier.service,
|
||||||
|
queued.id,
|
||||||
|
)
|
||||||
|
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
ts = async_to_sync(signalapi.send_message_raw)(
|
||||||
|
queued.session.identifier.identifier,
|
||||||
|
queued.text or "",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if not ts:
|
||||||
|
log.error("Queue accept send failed for queued message %s", queued.id)
|
||||||
|
return HttpResponse(status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
Message.objects.create(
|
||||||
|
user=queued.session.user,
|
||||||
|
session=queued.session,
|
||||||
|
custom_author=queued.custom_author or "BOT",
|
||||||
|
text=queued.text,
|
||||||
|
ts=ts,
|
||||||
|
)
|
||||||
|
queued.delete()
|
||||||
|
|
||||||
return HttpResponse(status=status.HTTP_200_OK)
|
return HttpResponse(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class RejectMessageAPI(LoginRequiredMixin, APIView):
|
class RejectMessageAPI(LoginRequiredMixin, APIView):
|
||||||
def get(self, request, message_id):
|
def get(self, request, message_id):
|
||||||
try:
|
try:
|
||||||
@@ -64,11 +71,12 @@ class RejectMessageAPI(LoginRequiredMixin, APIView):
|
|||||||
message.delete()
|
message.delete()
|
||||||
|
|
||||||
return HttpResponse(status=status.HTTP_200_OK)
|
return HttpResponse(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class QueueList(LoginRequiredMixin, ObjectList):
|
class QueueList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/queue-list.html"
|
list_template = "partials/queue-list.html"
|
||||||
model = QueuedMessage
|
model = QueuedMessage
|
||||||
page_title = "Queues"
|
page_title = "Queue"
|
||||||
|
|
||||||
list_url_name = "queues"
|
list_url_name = "queues"
|
||||||
list_url_args = ["type"]
|
list_url_args = ["type"]
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
|
||||||
from mixins.views import (
|
|
||||||
ObjectCreate,
|
|
||||||
ObjectDelete,
|
|
||||||
ObjectList,
|
|
||||||
ObjectUpdate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core.forms import SessionForm
|
from core.forms import SessionForm
|
||||||
from core.models import ChatSession
|
from core.models import ChatSession
|
||||||
@@ -13,11 +7,12 @@ from core.util import logs
|
|||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SessionList(LoginRequiredMixin, ObjectList):
|
class SessionList(LoginRequiredMixin, ObjectList):
|
||||||
list_template = "partials/session-list.html"
|
list_template = "partials/session-list.html"
|
||||||
model = ChatSession
|
model = ChatSession
|
||||||
page_title = "Chat Sessions"
|
page_title = "Chat Sessions"
|
||||||
#page_subtitle = "Add times here in order to permit trading."
|
# page_subtitle = "Add times here in order to permit trading."
|
||||||
|
|
||||||
list_url_name = "sessions"
|
list_url_name = "sessions"
|
||||||
list_url_args = ["type"]
|
list_url_args = ["type"]
|
||||||
@@ -41,4 +36,3 @@ class SessionUpdate(LoginRequiredMixin, ObjectUpdate):
|
|||||||
|
|
||||||
class SessionDelete(LoginRequiredMixin, ObjectDelete):
|
class SessionDelete(LoginRequiredMixin, ObjectDelete):
|
||||||
model = ChatSession
|
model = ChatSession
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
|
||||||
from django.views import View
|
|
||||||
from django.shortcuts import render
|
|
||||||
import base64
|
import base64
|
||||||
from core.models import Chat
|
|
||||||
|
|
||||||
from mixins.views import ObjectRead, ObjectList
|
|
||||||
import requests
|
|
||||||
import orjson
|
import orjson
|
||||||
|
import requests
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views import View
|
||||||
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
|
from core.models import Chat
|
||||||
|
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||||
|
|
||||||
|
|
||||||
class CustomObjectRead(ObjectRead):
|
class CustomObjectRead(ObjectRead):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
self.request = request
|
self.request = request
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Signal(SuperUserRequiredMixin, View):
|
class Signal(SuperUserRequiredMixin, View):
|
||||||
template_name = "pages/signal.html"
|
template_name = "pages/signal.html"
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
return render(request, self.template_name)
|
return render(request, self.template_name)
|
||||||
|
|
||||||
|
|
||||||
class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
||||||
list_template = "partials/signal-accounts.html"
|
list_template = "partials/signal-accounts.html"
|
||||||
|
|
||||||
@@ -36,6 +40,7 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
|
|||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
||||||
list_template = "partials/signal-contacts-list.html"
|
list_template = "partials/signal-contacts-list.html"
|
||||||
|
|
||||||
@@ -45,7 +50,6 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
list_url_name = "signal_contacts"
|
list_url_name = "signal_contacts"
|
||||||
list_url_args = ["type", "pk"]
|
list_url_args = ["type", "pk"]
|
||||||
|
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
# url = signal:8080/v1/accounts
|
# url = signal:8080/v1/accounts
|
||||||
# /v1/configuration/{number}/settings
|
# /v1/configuration/{number}/settings
|
||||||
@@ -67,13 +71,14 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
contact["identity"] = identity
|
contact["identity"] = identity
|
||||||
|
|
||||||
obj = {
|
obj = {
|
||||||
#"identity": identity,
|
# "identity": identity,
|
||||||
"contacts": contacts,
|
"contacts": contacts,
|
||||||
}
|
}
|
||||||
self.extra_context = {"pretty": list(obj.keys())}
|
self.extra_context = {"pretty": list(obj.keys())}
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||||
list_template = "partials/signal-chats-list.html"
|
list_template = "partials/signal-chats-list.html"
|
||||||
|
|
||||||
@@ -82,15 +87,17 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
|
|
||||||
list_url_name = "signal_chats"
|
list_url_name = "signal_chats"
|
||||||
list_url_args = ["type", "pk"]
|
list_url_args = ["type", "pk"]
|
||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
def get_queryset(self, *args, **kwargs):
|
||||||
pk = self.kwargs.get("pk", "")
|
pk = self.kwargs.get("pk", "")
|
||||||
object_list = Chat.objects.filter(account=pk)
|
object_list = Chat.objects.filter(account=pk)
|
||||||
return object_list
|
return object_list
|
||||||
|
|
||||||
|
|
||||||
class SignalMessagesList(SuperUserRequiredMixin, ObjectList):
|
class SignalMessagesList(SuperUserRequiredMixin, ObjectList):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
|
class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
|
||||||
detail_template = "partials/signal-account-add.html"
|
detail_template = "partials/signal-account-add.html"
|
||||||
|
|
||||||
@@ -107,7 +114,7 @@ class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
|
|||||||
device_name = form_args["device"]
|
device_name = form_args["device"]
|
||||||
url = f"http://signal:8080/v1/qrcodelink?device_name={device_name}"
|
url = f"http://signal:8080/v1/qrcodelink?device_name={device_name}"
|
||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
image_bytes = response.content
|
image_bytes = response.content
|
||||||
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
base64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
|
||||||
return base64_image
|
return base64_image
|
||||||
|
|||||||
2864
core/views/workspace.py
Normal file
2864
core/views/workspace.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user