Implement AI workspace and mitigation workflow

This commit is contained in:
2026-02-15 04:27:28 +00:00
parent de2b9a9bbb
commit 2d3b8fdac6
64 changed files with 7669 additions and 769 deletions

View File

@@ -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")

View File

@@ -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(),

View File

@@ -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()

View File

@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from core.util import logs from core.util import logs

View File

@@ -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))

View File

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

View File

@@ -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()

View File

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

View File

@@ -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"}),
)

View File

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

View File

@@ -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, its late there. whats up? Me: hi, its late there. whats up?
""" """

View File

@@ -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}"

View File

@@ -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):
""" """

View File

@@ -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()

View File

@@ -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,

View File

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

View File

@@ -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)()

View File

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

View File

@@ -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 personas tone and style Strictly rewrites the message in the personas 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"
) )

View File

@@ -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)()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'),
),
]

View 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)),
],
),
]

View File

@@ -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)),
],
),
]

View 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)),
],
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View 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)),
],
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -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 %}

View File

@@ -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 %}

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

View File

@@ -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"

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

View 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 &amp; 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>

View File

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

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

View File

@@ -0,0 +1,3 @@
<div class="notification is-{{ level }} is-light" style="padding: 0.55rem 0.75rem;">
{{ message }}
</div>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %}

View File

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

View File

@@ -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 %}

View File

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

View File

@@ -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 %}

View File

@@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]

View File

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

View File

@@ -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"]

View File

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

View File

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

File diff suppressed because it is too large Load Diff