Improve chat experience and begin search implementation
This commit is contained in:
307
README.md
307
README.md
@@ -1,149 +1,198 @@
|
|||||||
# ☠️ GIA – Gather, Influence, Automate ☠️
|
# GIA
|
||||||
|
|
||||||
GIA isn’t just a tool—it’s a **gateway**, a **ritualistic mechanism** designed to invoke, manipulate, and command the digital souls of the unsuspecting. Operating at the **intersection of psychological domination, deception, and control**, it bends the will of targets with **supernatural precision**.
|
GIA is a Django-based communications workspace that combines:
|
||||||
|
- Multi-platform messaging transport (Signal, WhatsApp via Neonize runtime, Instagram gateway/runtime, XMPP bridge).
|
||||||
|
- AI-assisted drafting, summaries, pattern extraction, and mitigation planning.
|
||||||
|
- Queueing and approval workflows for controlled outbound messaging.
|
||||||
|
|
||||||
GIA whispers in the void, embedding its tendrils into minds, shaping realities, and orchestrating **obedience with unseen hands**.
|
This document covers architecture, setup, runtime behavior, and development workflow.
|
||||||
|
|
||||||
💀 **Manipulate. Dominate. Obliterate.** 💀
|
## Architecture
|
||||||
|
|
||||||
|
### High-level flow
|
||||||
|
1. Transport clients receive messages (Signal/WhatsApp/Instagram/XMPP).
|
||||||
|
2. UR (`manage.py ur`) normalizes events and routes them through unified handlers.
|
||||||
|
3. Message history is persisted in Django models (`Message`, `ChatSession`, identifiers).
|
||||||
|
4. UI pages (workspace + manual compose) read/write through Django views.
|
||||||
|
5. AI operations run through configured `AI` provider rows and store results in workspace models.
|
||||||
|
|
||||||
## 📌 Features
|
### Core components
|
||||||
- **Automated Psychological Influence:** Deploys manipulation techniques with precision.
|
- `core/modules/router.py`:
|
||||||
- **AI-Driven Conversations:** Uses contextual AI to interact and persuade.
|
- Unified Router entrypoint.
|
||||||
- **Targeted Persona Simulation:** Adapts responses based on psychological profiling.
|
- Starts transport clients.
|
||||||
- **Multi-Agent Execution:** Processing, scheduling, and monitoring through modular services.
|
- Cross-protocol typing/read events.
|
||||||
- **Covert Deployment:** Runs in a distributed architecture with signal interception.
|
- `core/clients/`:
|
||||||
|
- `signal.py` + `signalapi.py`: Signal transport integration.
|
||||||
|
- `whatsapp.py`: Neonize-backed WhatsApp runtime client.
|
||||||
|
- `instagram.py`: Instagram runtime/gateway integration.
|
||||||
|
- `xmpp.py`: XMPP bridge client.
|
||||||
|
- `transport.py`: Unified send/typing/QR API used by views/models.
|
||||||
|
- `core/messaging/`:
|
||||||
|
- AI execution wrapper.
|
||||||
|
- Message history persistence and enrichment helpers.
|
||||||
|
- `core/views/`:
|
||||||
|
- `workspace.py`: AI workspace, insights, mitigation plans/artifacts.
|
||||||
|
- `compose.py`: Manual chat mode, AI drafts/summary/engage shortcuts.
|
||||||
|
- `signal.py`, `whatsapp.py`, `instagram.py`: Service setup/account pages.
|
||||||
|
|
||||||
## 🚀 Quickstart Guide
|
### Data model highlights
|
||||||
|
- `Person` + `PersonIdentifier`:
|
||||||
|
- Identity layer across services (`signal`, `whatsapp`, `instagram`, `xmpp`).
|
||||||
|
- `ChatSession` + `Message`:
|
||||||
|
- Conversation storage.
|
||||||
|
- Delivery/read metadata fields on `Message`.
|
||||||
|
- `WorkspaceConversation`:
|
||||||
|
- AI workspace context per person/thread.
|
||||||
|
- Stability + commitment scores and confidence.
|
||||||
|
- `WorkspaceMetricSnapshot`:
|
||||||
|
- Historical metric snapshots for trend charts.
|
||||||
|
- Pattern mitigation models:
|
||||||
|
- `PatternMitigationPlan`, `Rule`, `Game`, `Correction`, exports, and auto settings.
|
||||||
|
|
||||||
### 🔧 Setting Up the Environment
|
## Runtime services
|
||||||
GIA runs inside **Podman containers**. Ensure you have **Podman** and **Podman Compose** installed. Docker might work. Results may vary.
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
`docker-compose.yml` defines these primary services:
|
||||||
```shell
|
- `app`: Django web process.
|
||||||
❯ git clone https://github.com/your-repo/gia.git
|
- `ur`: Unified Router runtime (transport clients).
|
||||||
❯ cd gia
|
- `scheduling`: Scheduled jobs.
|
||||||
```
|
- `migration`: Startup migrations.
|
||||||
|
- `collectstatic`: Static asset collection.
|
||||||
|
- `redis`: Cache/runtime signaling.
|
||||||
|
- `signal-cli-rest-api`: Signal backend.
|
||||||
|
|
||||||
2. Set up the environment variables
|
## Installation
|
||||||
```shell
|
|
||||||
❯ cp stack.env.example stack.env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Edit `stack.env`
|
### Prerequisites
|
||||||
|
- Podman + Podman Compose (preferred in this repo), or Docker Compose.
|
||||||
|
- Linux environment with bind-mount access for configured paths.
|
||||||
|
|
||||||
4. Build and start the containers
|
### 1) Clone and configure
|
||||||
```shell
|
```bash
|
||||||
❯ make build
|
git clone <your-repo-url> GIA
|
||||||
❯ make run
|
cd GIA
|
||||||
```
|
cp stack.env.example stack.env
|
||||||
|
|
||||||
5. Run database migrations
|
|
||||||
```shell
|
|
||||||
❯ make migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Create a superuser for Django Admin (optional but recommended)
|
|
||||||
```shell
|
|
||||||
❯ make auth
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Monitor logs
|
|
||||||
```shell
|
|
||||||
❯ make log
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚙️ Deployment & Architecture
|
|
||||||
### 🏗️ Services Overview
|
|
||||||
| Service | Description |
|
|
||||||
|----------------|-------------|
|
|
||||||
| **app** | Main application container running **Uvicorn** for API handling. |
|
|
||||||
| **db** | ManticoreSearch-based database backend. |
|
|
||||||
| **redis** | Message queue for task distribution. |
|
|
||||||
| **signal-cli** | Handles Signal communications. |
|
|
||||||
| **processing** | Processes incoming messages and executes manipulations. |
|
|
||||||
| **scheduling** | Handles timed tasks and influence scheduling. |
|
|
||||||
| **migration** | Runs database migrations automatically on startup. |
|
|
||||||
| **collectstatic** | Collects static files for Django before launch. |
|
|
||||||
|
|
||||||
## 🔥 Running Commands in a Container
|
|
||||||
|
|
||||||
You can execute management commands inside the app container using:
|
|
||||||
```shell
|
|
||||||
❯ docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py <command>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛑 Stopping GIA
|
|
||||||
To stop all running services:
|
|
||||||
```shell
|
|
||||||
❯ make stop
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🕵️♂️ Operational Modes
|
|
||||||
GIA runs in different operation modes set via OPERATION:
|
|
||||||
| Mode | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| **uwsgi** | Runs behind Nginx for production. |
|
|
||||||
| **dev** | Direct execution via Django's built-in server (development mode). |
|
|
||||||
|
|
||||||
|
|
||||||
The default Podman entrypoint dynamically selects the correct process based on OPERATION.
|
|
||||||
Be sure to uncomment nginx if using dev, as the shipped setup expects an external `nginx` instance to point to the GIA `uwsgi` sock:
|
|
||||||
```
|
|
||||||
location / {
|
|
||||||
include include/xf-only.conf;
|
|
||||||
include /etc/nginx/uwsgi_params;
|
|
||||||
uwsgi_pass unix:///code/vrun/uwsgi-gia.sock;
|
|
||||||
uwsgi_param Host $host;
|
|
||||||
uwsgi_param X-Real-IP $remote_addr;
|
|
||||||
uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto;
|
|
||||||
}
|
|
||||||
location /static {
|
|
||||||
alias /code/xf/GIA/static/;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 Persistent Data & Storage
|
Edit `stack.env` with at least:
|
||||||
| Mount Path (Host) | Purpose |
|
- `SECRET_KEY`
|
||||||
|---------------------------|-------------|
|
- `ALLOWED_HOSTS`
|
||||||
| **docker/uwsgi.ini** | Configuration for **uWSGI** execution. |
|
- `CSRF_TRUSTED_ORIGINS`
|
||||||
| **db.sqlite3** | SQLite database storage. |
|
- `SIGNAL_NUMBER` (if Signal enabled)
|
||||||
| **/code/vrun/** | Sockets shared between services. |
|
- `WHATSAPP_ENABLED=true|false`
|
||||||
| **signal-cli-config/** | Stores **Signal CLI** configuration and keys. |
|
- `INSTAGRAM_ENABLED=true|false`
|
||||||
|
- XMPP values if XMPP bridge is enabled
|
||||||
|
|
||||||
## 🔧 Additional Configuration
|
### 2) Build and start
|
||||||
### Django Environment Variables
|
```bash
|
||||||
|
make build
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
The following are required for proper operation:
|
### 3) Run migrations and create admin user
|
||||||
```shell
|
```bash
|
||||||
APP_PORT=5006
|
make migrate
|
||||||
REPO_DIR=.
|
make auth
|
||||||
APP_LOCAL_SETTINGS=./app/local_settings.py
|
```
|
||||||
APP_DATABASE_FILE=./db.sqlite3
|
|
||||||
DOMAIN=example.com
|
|
||||||
URL=https://example.com
|
|
||||||
ALLOWED_HOSTS=example.com
|
|
||||||
NOTIFY_TOPIC=example-topic
|
|
||||||
CSRF_TRUSTED_ORIGINS=https://example.com
|
|
||||||
DEBUG=y
|
|
||||||
SECRET_KEY=
|
|
||||||
STATIC_ROOT=/code/static
|
|
||||||
REGISTRATION_OPEN=0
|
|
||||||
OPERATION=uwsgi
|
|
||||||
BILLING_ENABLED=0
|
|
||||||
```
|
|
||||||
|
|
||||||
These can be set inside `stack.env`.
|
### 4) Follow logs
|
||||||
|
```bash
|
||||||
|
make log
|
||||||
|
```
|
||||||
|
|
||||||
## ⚠️ Legal Disclaimer
|
## Local developer commands
|
||||||
|
|
||||||
**GIA is provided for research and educational purposes only.**
|
```bash
|
||||||
The developers and contributors are not responsible for any **misuse, illegal activities, or consequences** resulting from the use of this software.
|
make makemigrations
|
||||||
|
make migrate
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
By using GIA, you **acknowledge and agree** that:
|
Run ad-hoc manage commands:
|
||||||
- You are solely responsible for your actions and compliance with applicable laws.
|
```bash
|
||||||
- The software should not be used for unauthorized surveillance, coercion, or unethical influence.
|
docker-compose --env-file=stack.env run --rm app \
|
||||||
- The authors disclaim all liability for damages resulting from its use.
|
sh -c ". /venv/bin/activate && python manage.py <command>"
|
||||||
|
```
|
||||||
|
|
||||||
GIA is a tool that must be used responsibly. **Do not deploy it where it violates privacy laws, cybersecurity regulations, or ethical guidelines.**
|
## Service setup pages
|
||||||
|
|
||||||
|
- Signal: `/services/signal/`
|
||||||
|
- WhatsApp: `/services/whatsapp/`
|
||||||
|
- Instagram: `/services/instagram/`
|
||||||
|
|
||||||
|
### WhatsApp pairing (Neonize)
|
||||||
|
WhatsApp account linking expects a real Neonize runtime QR:
|
||||||
|
1. Start stack with `WHATSAPP_ENABLED=true`.
|
||||||
|
2. Ensure `ur` is running and WhatsApp runtime initialized.
|
||||||
|
3. Open `Services -> WhatsApp -> Add Account`.
|
||||||
|
4. Scan QR from WhatsApp Linked Devices.
|
||||||
|
|
||||||
|
If runtime QR is not available yet, UI returns a clear warning instead of a synthetic QR.
|
||||||
|
|
||||||
|
## Manual compose mode
|
||||||
|
|
||||||
|
- Route: `/compose/page/?service=<service>&identifier=<id>[&person=<uuid>]`
|
||||||
|
- Features:
|
||||||
|
- Chat thread view.
|
||||||
|
- Send with two-step failsafe (`Arm Send` + `Confirm Intent`).
|
||||||
|
- AI overlay tools:
|
||||||
|
- Draft suggestions.
|
||||||
|
- Conversation summary.
|
||||||
|
- Quick engage send (shared framing, with its own two-step failsafe).
|
||||||
|
- Click-outside dismiss behavior for AI overlays.
|
||||||
|
|
||||||
|
### Live updates and traffic minimization
|
||||||
|
- WebSocket endpoint: `/ws/compose/thread/` (ASGI path).
|
||||||
|
- Compose clients open one persistent WS per panel and receive incremental updates.
|
||||||
|
- HTTP polling remains as a fallback at a slower interval when WS is unavailable.
|
||||||
|
|
||||||
|
This reduces repeated client GET requests compared with short polling loops.
|
||||||
|
|
||||||
|
## AI workspace
|
||||||
|
|
||||||
|
Route: `/ai/workspace/`
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
- Summary, draft, pattern extraction.
|
||||||
|
- Mitigation planning and artifacts (rules/games/corrections).
|
||||||
|
- Insight metrics:
|
||||||
|
- Detail pages per metric.
|
||||||
|
- All-graphs page using Chart.js.
|
||||||
|
- Scoring help page describing formulas and interpretation.
|
||||||
|
|
||||||
|
## Configuration notes
|
||||||
|
|
||||||
|
- `app/local_settings.py` is imported by `app/settings.py`.
|
||||||
|
- Redis cache is expected at `/var/run/gia-redis.sock` unless overridden.
|
||||||
|
- Service runtime URLs are read by `core/clients/transport.py`:
|
||||||
|
- `SIGNAL_HTTP_URL`
|
||||||
|
- `WHATSAPP_HTTP_URL`
|
||||||
|
- `INSTAGRAM_HTTP_URL`
|
||||||
|
|
||||||
|
## ASGI / WebSocket note
|
||||||
|
|
||||||
|
`app/asgi.py` now multiplexes:
|
||||||
|
- HTTP to Django ASGI app.
|
||||||
|
- WebSocket path `/ws/compose/thread/` to compose realtime handler.
|
||||||
|
|
||||||
|
Use an ASGI-capable server in environments where WebSockets are required.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### “Send failed. Check service account state.”
|
||||||
|
- Verify service account is linked and runtime is connected.
|
||||||
|
- Check `ur` and service logs (`make log`).
|
||||||
|
|
||||||
|
### WhatsApp QR not available
|
||||||
|
- Confirm `WHATSAPP_ENABLED=true`.
|
||||||
|
- Confirm Neonize initializes in `ur` logs.
|
||||||
|
- Retry Add Account after runtime is ready.
|
||||||
|
|
||||||
|
### No compose realtime updates
|
||||||
|
- Verify ASGI deployment path and websocket upgrade support.
|
||||||
|
- Fallback polling should still update messages on interval.
|
||||||
|
|
||||||
|
## Security and operational safety
|
||||||
|
|
||||||
|
- Outbound actions in manual compose/engage are guarded by two-step failsafe toggles.
|
||||||
|
- Queue workflow exists for additional human approval in managed flows.
|
||||||
|
- Deploy only with lawful consent and compliant data handling for your jurisdiction.
|
||||||
|
|||||||
15
app/asgi.py
15
app/asgi.py
@@ -13,4 +13,17 @@ from django.core.asgi import get_asgi_application
|
|||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||||
|
|
||||||
application = get_asgi_application()
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
|
|
||||||
|
async def application(scope, receive, send):
|
||||||
|
if scope.get("type") == "websocket":
|
||||||
|
path = scope.get("path", "")
|
||||||
|
if path == "/ws/compose/thread/":
|
||||||
|
from core.realtime.compose_ws import compose_ws_application
|
||||||
|
|
||||||
|
await compose_ws_application(scope, receive, send)
|
||||||
|
return
|
||||||
|
await send({"type": "websocket.close", "code": 4404})
|
||||||
|
return
|
||||||
|
await django_asgi_app(scope, receive, send)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ MIDDLEWARE = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "app.urls"
|
ROOT_URLCONF = "app.urls"
|
||||||
|
ASGI_APPLICATION = "app.asgi.application"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
|
|||||||
26
app/urls.py
26
app/urls.py
@@ -30,6 +30,7 @@ from core.views import (
|
|||||||
manipulations,
|
manipulations,
|
||||||
messages,
|
messages,
|
||||||
notifications,
|
notifications,
|
||||||
|
osint,
|
||||||
people,
|
people,
|
||||||
personas,
|
personas,
|
||||||
queues,
|
queues,
|
||||||
@@ -128,6 +129,26 @@ urlpatterns = [
|
|||||||
compose.ComposeSend.as_view(),
|
compose.ComposeSend.as_view(),
|
||||||
name="compose_send",
|
name="compose_send",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/drafts/",
|
||||||
|
compose.ComposeDrafts.as_view(),
|
||||||
|
name="compose_drafts",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"compose/summary/",
|
||||||
|
compose.ComposeSummary.as_view(),
|
||||||
|
name="compose_summary",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"compose/engage/preview/",
|
||||||
|
compose.ComposeEngagePreview.as_view(),
|
||||||
|
name="compose_engage_preview",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"compose/engage/send/",
|
||||||
|
compose.ComposeEngageSend.as_view(),
|
||||||
|
name="compose_engage_send",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/thread/",
|
"compose/thread/",
|
||||||
compose.ComposeThread.as_view(),
|
compose.ComposeThread.as_view(),
|
||||||
@@ -254,6 +275,11 @@ urlpatterns = [
|
|||||||
ais.AIList.as_view(),
|
ais.AIList.as_view(),
|
||||||
name="ais",
|
name="ais",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"search/<str:type>/",
|
||||||
|
osint.OSINTSearch.as_view(),
|
||||||
|
name="osint_search",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"ai/<str:type>/create/",
|
"ai/<str:type>/create/",
|
||||||
ais.AICreate.as_view(),
|
ais.AICreate.as_view(),
|
||||||
|
|||||||
@@ -397,6 +397,12 @@ def get_link_qr(service: str, device_name: str):
|
|||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
if service_key == "whatsapp":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Neonize has not provided a pairing QR yet. "
|
||||||
|
"Ensure UR is running with WHATSAPP_ENABLED=true and retry."
|
||||||
|
)
|
||||||
|
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
uri = f"gia://{service_key}/link?device={device}&token={token}"
|
uri = f"gia://{service_key}/link?device={device}&token={token}"
|
||||||
update_runtime_state(
|
update_runtime_state(
|
||||||
|
|||||||
162
core/realtime/compose_ws.py
Normal file
162
core/realtime/compose_ws.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.core import signing
|
||||||
|
|
||||||
|
from core.models import ChatSession, Message, PersonIdentifier
|
||||||
|
from core.views.compose import COMPOSE_WS_TOKEN_SALT
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value, default=0):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(ts_value):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
|
||||||
|
return dt.strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
return str(ts_value or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_message(msg):
|
||||||
|
author = str(msg.custom_author or "").strip()
|
||||||
|
return {
|
||||||
|
"id": str(msg.id),
|
||||||
|
"ts": int(msg.ts or 0),
|
||||||
|
"display_ts": _fmt_ts(msg.ts),
|
||||||
|
"text": str(msg.text or ""),
|
||||||
|
"author": author,
|
||||||
|
"outgoing": author.upper() in {"USER", "BOT"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_since(user_id, service, identifier, person_id, after_ts, limit):
|
||||||
|
person_identifier = None
|
||||||
|
if person_id:
|
||||||
|
person_identifier = (
|
||||||
|
PersonIdentifier.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
person_id=person_id,
|
||||||
|
service=service,
|
||||||
|
).first()
|
||||||
|
or PersonIdentifier.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
person_id=person_id,
|
||||||
|
).first()
|
||||||
|
)
|
||||||
|
if person_identifier is None and identifier:
|
||||||
|
person_identifier = PersonIdentifier.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
service=service,
|
||||||
|
identifier=identifier,
|
||||||
|
).first()
|
||||||
|
if person_identifier is None:
|
||||||
|
return {"messages": [], "last_ts": after_ts}
|
||||||
|
|
||||||
|
session = ChatSession.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
identifier=person_identifier,
|
||||||
|
).first()
|
||||||
|
if session is None:
|
||||||
|
return {"messages": [], "last_ts": after_ts}
|
||||||
|
|
||||||
|
qs = Message.objects.filter(
|
||||||
|
user_id=user_id,
|
||||||
|
session=session,
|
||||||
|
).order_by("ts")
|
||||||
|
if after_ts > 0:
|
||||||
|
qs = qs.filter(ts__gt=after_ts)
|
||||||
|
|
||||||
|
rows = list(qs[: max(10, min(limit, 200))])
|
||||||
|
newest = (
|
||||||
|
Message.objects.filter(user_id=user_id, session=session)
|
||||||
|
.order_by("-ts")
|
||||||
|
.values_list("ts", flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"messages": [_serialize_message(row) for row in rows],
|
||||||
|
"last_ts": int(newest or after_ts or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def compose_ws_application(scope, receive, send):
|
||||||
|
if scope.get("type") != "websocket":
|
||||||
|
return
|
||||||
|
|
||||||
|
query_string = (scope.get("query_string") or b"").decode("utf-8", errors="ignore")
|
||||||
|
params = parse_qs(query_string)
|
||||||
|
token = (params.get("token") or [""])[0]
|
||||||
|
try:
|
||||||
|
payload = signing.loads(token, salt=COMPOSE_WS_TOKEN_SALT)
|
||||||
|
except Exception:
|
||||||
|
await send({"type": "websocket.close", "code": 4401})
|
||||||
|
return
|
||||||
|
|
||||||
|
if _safe_int(payload.get("exp")) < int(time.time()):
|
||||||
|
await send({"type": "websocket.close", "code": 4401})
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = _safe_int(payload.get("u"))
|
||||||
|
service = str(payload.get("s") or "").strip()
|
||||||
|
identifier = str(payload.get("i") or "").strip()
|
||||||
|
person_id = str(payload.get("p") or "").strip()
|
||||||
|
|
||||||
|
if user_id <= 0 or (not identifier and not person_id):
|
||||||
|
await send({"type": "websocket.close", "code": 4401})
|
||||||
|
return
|
||||||
|
|
||||||
|
await send({"type": "websocket.accept"})
|
||||||
|
last_ts = 0
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
while True:
|
||||||
|
event = None
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(receive(), timeout=1.2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
event = None
|
||||||
|
|
||||||
|
if event and event.get("type") == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
if event and event.get("type") == "websocket.receive":
|
||||||
|
try:
|
||||||
|
body = json.loads(event.get("text") or "{}")
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
if body.get("kind") == "sync":
|
||||||
|
last_ts = max(last_ts, _safe_int(body.get("last_ts"), 0))
|
||||||
|
|
||||||
|
payload = await sync_to_async(_load_since)(
|
||||||
|
user_id=user_id,
|
||||||
|
service=service,
|
||||||
|
identifier=identifier,
|
||||||
|
person_id=person_id,
|
||||||
|
after_ts=last_ts,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
messages = payload.get("messages") or []
|
||||||
|
latest = _safe_int(payload.get("last_ts"), last_ts)
|
||||||
|
if messages:
|
||||||
|
last_ts = max(last_ts, latest)
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "websocket.send",
|
||||||
|
"text": json.dumps(
|
||||||
|
{
|
||||||
|
"messages": messages,
|
||||||
|
"last_ts": last_ts,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
last_ts = max(last_ts, latest)
|
||||||
@@ -212,6 +212,43 @@
|
|||||||
z-index: 39 !important;
|
z-index: 39 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.osint-table-shell {
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.osint-table-toolbar {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osint-results-table-wrap {
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osint-results-table th {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osint-sort-link {
|
||||||
|
color: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osint-sort-link:hover {
|
||||||
|
color: #3273dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osint-search-form .button.is-fullwidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -235,6 +272,9 @@
|
|||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
|
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
|
||||||
|
Search
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
<a class="navbar-link">
|
<a class="navbar-link">
|
||||||
|
|||||||
27
core/templates/pages/osint-search.html
Normal file
27
core/templates/pages/osint-search.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-11-tablet is-10-desktop is-9-widescreen">
|
||||||
|
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="title is-4">Search</h1>
|
||||||
|
<p class="subtitle is-6">
|
||||||
|
Search across OSINT objects with sortable, paginated results.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
hx-get="{{ search_widget_url }}"
|
||||||
|
hx-target="#widgets-here"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
onclick="document.getElementById('widgets-here').style.display = 'block';">
|
||||||
|
Open Widget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "partials/osint/search-panel.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,6 +14,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons are-small" style="margin: 0;">
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
|
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="drafts">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
|
||||||
|
<span>Drafts</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="summary">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-list"></i></span>
|
||||||
|
<span>Summary</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button is-light is-rounded js-ai-trigger" data-kind="engage">
|
||||||
|
<span class="icon is-small"><i class="fa-solid fa-handshake"></i></span>
|
||||||
|
<span>Engage</span>
|
||||||
|
</button>
|
||||||
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
|
<a class="button is-light is-rounded" href="{{ ai_workspace_url }}">
|
||||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||||
<span>AI Workspace</span>
|
<span>AI Workspace</span>
|
||||||
@@ -25,6 +37,46 @@
|
|||||||
{% include "partials/compose-send-status.html" %}
|
{% include "partials/compose-send-status.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="{{ panel_id }}-popover" class="compose-ai-popover is-hidden">
|
||||||
|
<div class="compose-ai-card" data-kind="drafts">
|
||||||
|
<p class="compose-ai-title">Draft Suggestions</p>
|
||||||
|
<div class="compose-ai-loading">
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
</div>
|
||||||
|
<div class="compose-ai-content"></div>
|
||||||
|
</div>
|
||||||
|
<div class="compose-ai-card" data-kind="summary">
|
||||||
|
<p class="compose-ai-title">Conversation Summary</p>
|
||||||
|
<div class="compose-ai-loading">
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
</div>
|
||||||
|
<div class="compose-ai-content"></div>
|
||||||
|
</div>
|
||||||
|
<div class="compose-ai-card" data-kind="engage">
|
||||||
|
<p class="compose-ai-title">Quick Engage (Shared Framing)</p>
|
||||||
|
<div class="compose-ai-loading">
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
<div class="compose-ai-skel"></div>
|
||||||
|
</div>
|
||||||
|
<div class="compose-ai-content"></div>
|
||||||
|
<div class="compose-ai-safety">
|
||||||
|
<label class="checkbox is-size-7">
|
||||||
|
<input type="checkbox" class="engage-arm"> Arm Send
|
||||||
|
</label>
|
||||||
|
<label class="checkbox is-size-7">
|
||||||
|
<input type="checkbox" class="engage-confirm"> Confirm Share To Other Party
|
||||||
|
</label>
|
||||||
|
<button type="button" class="button is-link is-light is-small engage-send-btn" disabled>
|
||||||
|
Send Engage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="{{ panel_id }}-thread"
|
id="{{ panel_id }}-thread"
|
||||||
class="compose-thread"
|
class="compose-thread"
|
||||||
@@ -33,7 +85,12 @@
|
|||||||
data-identifier="{{ identifier }}"
|
data-identifier="{{ identifier }}"
|
||||||
data-person="{% if person %}{{ person.id }}{% endif %}"
|
data-person="{% if person %}{{ person.id }}{% endif %}"
|
||||||
data-limit="{{ limit }}"
|
data-limit="{{ limit }}"
|
||||||
data-last-ts="{{ last_ts }}">
|
data-last-ts="{{ last_ts }}"
|
||||||
|
data-ws-url="{{ compose_ws_url }}"
|
||||||
|
data-drafts-url="{{ compose_drafts_url }}"
|
||||||
|
data-summary-url="{{ compose_summary_url }}"
|
||||||
|
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||||
|
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||||
{% for msg in serialized_messages %}
|
{% for msg in serialized_messages %}
|
||||||
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}" data-ts="{{ msg.ts }}">
|
||||||
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
|
||||||
@@ -59,9 +116,19 @@
|
|||||||
<input type="hidden" name="identifier" value="{{ identifier }}">
|
<input type="hidden" name="identifier" value="{{ identifier }}">
|
||||||
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
<input type="hidden" name="render_mode" value="{{ render_mode }}">
|
||||||
<input type="hidden" name="limit" value="{{ limit }}">
|
<input type="hidden" name="limit" value="{{ limit }}">
|
||||||
|
<input type="hidden" name="failsafe_arm" value="0">
|
||||||
|
<input type="hidden" name="failsafe_confirm" value="0">
|
||||||
{% if person %}
|
{% if person %}
|
||||||
<input type="hidden" name="person" value="{{ person.id }}">
|
<input type="hidden" name="person" value="{{ person.id }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="compose-send-safety">
|
||||||
|
<label class="checkbox is-size-7">
|
||||||
|
<input type="checkbox" class="manual-arm"> Arm Send
|
||||||
|
</label>
|
||||||
|
<label class="checkbox is-size-7">
|
||||||
|
<input type="checkbox" class="manual-confirm"> Confirm Intent
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="compose-composer-capsule">
|
<div class="compose-composer-capsule">
|
||||||
<textarea
|
<textarea
|
||||||
id="{{ panel_id }}-textarea"
|
id="{{ panel_id }}-textarea"
|
||||||
@@ -69,7 +136,7 @@
|
|||||||
name="text"
|
name="text"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
|
placeholder="Type a message. Enter to send, Shift+Enter for newline."></textarea>
|
||||||
<button class="button is-link is-light compose-send-btn" type="submit">
|
<button class="button is-link is-light compose-send-btn" type="submit" disabled>
|
||||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||||
<span>Send</span>
|
<span>Send</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -79,6 +146,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
#{{ panel_id }}.compose-shell {
|
#{{ panel_id }}.compose-shell {
|
||||||
|
position: relative;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.16);
|
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -164,10 +232,87 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-send-btn[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-send-safety {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: #505050;
|
||||||
|
}
|
||||||
#{{ panel_id }} .compose-status {
|
#{{ panel_id }} .compose-status {
|
||||||
margin-top: 0.55rem;
|
margin-top: 0.55rem;
|
||||||
min-height: 1.1rem;
|
min-height: 1.1rem;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 4.2rem;
|
||||||
|
right: 0.7rem;
|
||||||
|
width: min(34rem, calc(100% - 1.4rem));
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-popover.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-card {
|
||||||
|
display: none;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.65rem;
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-card.is-active {
|
||||||
|
display: block;
|
||||||
|
animation: composeFadeIn 160ms ease-out;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-loading.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-skel {
|
||||||
|
height: 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
background: linear-gradient(90deg, rgba(233, 236, 239, 0.8), rgba(210, 214, 218, 0.95), rgba(233, 236, 239, 0.8));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: composePulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-skel:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-draft-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-draft-option:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-safety {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@keyframes composePulse {
|
||||||
|
0% { background-position: 100% 0; }
|
||||||
|
100% { background-position: 0 0; }
|
||||||
|
}
|
||||||
|
@keyframes composeFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#{{ panel_id }} .compose-thread {
|
#{{ panel_id }} .compose-thread {
|
||||||
max-height: 52vh;
|
max-height: 52vh;
|
||||||
@@ -175,6 +320,11 @@
|
|||||||
#{{ panel_id }} .compose-send-btn span:last-child {
|
#{{ panel_id }} .compose-send-btn span:last-child {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
#{{ panel_id }} .compose-ai-popover {
|
||||||
|
left: 0.7rem;
|
||||||
|
right: 0.7rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -192,6 +342,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusBox = document.getElementById(panelId + "-status");
|
||||||
|
const popover = document.getElementById(panelId + "-popover");
|
||||||
|
const csrfToken = "{{ csrf_token }}";
|
||||||
|
|
||||||
window.giaComposePanels = window.giaComposePanels || {};
|
window.giaComposePanels = window.giaComposePanels || {};
|
||||||
const previousState = window.giaComposePanels[panelId];
|
const previousState = window.giaComposePanels[panelId];
|
||||||
if (previousState && previousState.timer) {
|
if (previousState && previousState.timer) {
|
||||||
@@ -200,7 +354,17 @@
|
|||||||
if (previousState && previousState.eventHandler) {
|
if (previousState && previousState.eventHandler) {
|
||||||
document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
|
document.body.removeEventListener("composeMessageSent", previousState.eventHandler);
|
||||||
}
|
}
|
||||||
const panelState = { timer: null, polling: false };
|
if (previousState && previousState.docClickHandler) {
|
||||||
|
document.removeEventListener("mousedown", previousState.docClickHandler);
|
||||||
|
}
|
||||||
|
const panelState = {
|
||||||
|
timer: null,
|
||||||
|
polling: false,
|
||||||
|
socket: null,
|
||||||
|
websocketReady: false,
|
||||||
|
activePanel: null,
|
||||||
|
engageToken: ""
|
||||||
|
};
|
||||||
window.giaComposePanels[panelId] = panelState;
|
window.giaComposePanels[panelId] = panelState;
|
||||||
|
|
||||||
const toInt = function (value) {
|
const toInt = function (value) {
|
||||||
@@ -259,8 +423,20 @@
|
|||||||
thread.appendChild(row);
|
thread.appendChild(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendMessages = function (messages, forceScroll) {
|
||||||
|
const shouldStick = nearBottom() || forceScroll;
|
||||||
|
(messages || []).forEach(function (msg) {
|
||||||
|
appendBubble(msg);
|
||||||
|
lastTs = Math.max(lastTs, toInt(msg.ts));
|
||||||
|
});
|
||||||
|
thread.dataset.lastTs = String(lastTs);
|
||||||
|
if ((messages || []).length > 0) {
|
||||||
|
scrollToBottom(shouldStick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const poll = async function (forceScroll) {
|
const poll = async function (forceScroll) {
|
||||||
if (panelState.polling) {
|
if (panelState.polling || panelState.websocketReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
panelState.polling = true;
|
panelState.polling = true;
|
||||||
@@ -273,28 +449,19 @@
|
|||||||
}
|
}
|
||||||
params.set("limit", thread.dataset.limit || "60");
|
params.set("limit", thread.dataset.limit || "60");
|
||||||
params.set("after_ts", String(lastTs));
|
params.set("after_ts", String(lastTs));
|
||||||
const url = thread.dataset.pollUrl + "?" + params.toString();
|
const response = await fetch(thread.dataset.pollUrl + "?" + params.toString(), {
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" }
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
appendMessages(payload.messages || [], forceScroll);
|
||||||
const shouldStick = nearBottom() || forceScroll;
|
|
||||||
messages.forEach(function (msg) {
|
|
||||||
appendBubble(msg);
|
|
||||||
lastTs = Math.max(lastTs, toInt(msg.ts));
|
|
||||||
});
|
|
||||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||||
}
|
thread.dataset.lastTs = String(lastTs);
|
||||||
thread.dataset.lastTs = String(lastTs);
|
|
||||||
if (messages.length > 0) {
|
|
||||||
scrollToBottom(shouldStick);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug("compose poll error", err);
|
console.debug("compose poll error", err);
|
||||||
@@ -303,9 +470,324 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setupWebSocket = function () {
|
||||||
|
const wsPath = thread.dataset.wsUrl || "";
|
||||||
|
if (!wsPath || !window.WebSocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||||
|
const socketUrl = protocol + window.location.host + wsPath;
|
||||||
|
try {
|
||||||
|
const socket = new WebSocket(socketUrl);
|
||||||
|
panelState.socket = socket;
|
||||||
|
socket.onopen = function () {
|
||||||
|
panelState.websocketReady = true;
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ kind: "sync", last_ts: lastTs }));
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onmessage = function (event) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data || "{}");
|
||||||
|
appendMessages(payload.messages || [], false);
|
||||||
|
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||||
|
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||||
|
thread.dataset.lastTs = String(lastTs);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("compose websocket payload error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = function () {
|
||||||
|
panelState.websocketReady = false;
|
||||||
|
};
|
||||||
|
socket.onerror = function () {
|
||||||
|
panelState.websocketReady = false;
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
panelState.websocketReady = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const manualArm = form.querySelector(".manual-arm");
|
||||||
|
const manualConfirm = form.querySelector(".manual-confirm");
|
||||||
|
const armInput = form.querySelector("input[name='failsafe_arm']");
|
||||||
|
const confirmInput = form.querySelector("input[name='failsafe_confirm']");
|
||||||
|
const sendButton = form.querySelector(".compose-send-btn");
|
||||||
|
const updateManualSafety = function () {
|
||||||
|
const arm = !!(manualArm && manualArm.checked);
|
||||||
|
const confirm = !!(manualConfirm && manualConfirm.checked);
|
||||||
|
if (armInput) {
|
||||||
|
armInput.value = arm ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (confirmInput) {
|
||||||
|
confirmInput.value = confirm ? "1" : "0";
|
||||||
|
}
|
||||||
|
if (sendButton) {
|
||||||
|
sendButton.disabled = !(arm && confirm);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (manualArm) {
|
||||||
|
manualArm.addEventListener("change", updateManualSafety);
|
||||||
|
}
|
||||||
|
if (manualConfirm) {
|
||||||
|
manualConfirm.addEventListener("change", updateManualSafety);
|
||||||
|
}
|
||||||
|
updateManualSafety();
|
||||||
|
|
||||||
|
const setStatus = function (message, level) {
|
||||||
|
if (!statusBox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
statusBox.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusBox.innerHTML = '<article class="notification is-' + (level || "info") + ' is-light" style="padding:0.45rem 0.65rem; margin:0;">' + message + "</article>";
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideAllCards = function () {
|
||||||
|
if (!popover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
popover.classList.add("is-hidden");
|
||||||
|
popover.querySelectorAll(".compose-ai-card").forEach(function (card) {
|
||||||
|
card.classList.remove("is-active");
|
||||||
|
});
|
||||||
|
panelState.activePanel = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCard = function (kind) {
|
||||||
|
if (!popover) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
popover.classList.remove("is-hidden");
|
||||||
|
let active = null;
|
||||||
|
popover.querySelectorAll(".compose-ai-card").forEach(function (card) {
|
||||||
|
const isActive = card.dataset.kind === kind;
|
||||||
|
card.classList.toggle("is-active", isActive);
|
||||||
|
if (isActive) {
|
||||||
|
active = card;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
panelState.activePanel = kind;
|
||||||
|
return active;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryParams = function () {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("service", thread.dataset.service || "");
|
||||||
|
params.set("identifier", thread.dataset.identifier || "");
|
||||||
|
if (thread.dataset.person) {
|
||||||
|
params.set("person", thread.dataset.person);
|
||||||
|
}
|
||||||
|
params.set("limit", thread.dataset.limit || "60");
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCardLoading = function (card, loading) {
|
||||||
|
const loadingNode = card.querySelector(".compose-ai-loading");
|
||||||
|
const contentNode = card.querySelector(".compose-ai-content");
|
||||||
|
if (loadingNode) {
|
||||||
|
loadingNode.classList.toggle("is-hidden", !loading);
|
||||||
|
}
|
||||||
|
if (contentNode && loading) {
|
||||||
|
contentNode.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDrafts = async function () {
|
||||||
|
const card = showCard("drafts");
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCardLoading(card, true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(thread.dataset.draftsUrl + "?" + queryParams().toString(), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
setCardLoading(card, false);
|
||||||
|
if (!payload.ok) {
|
||||||
|
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load drafts.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
|
||||||
|
const container = card.querySelector(".compose-ai-content");
|
||||||
|
container.innerHTML = "";
|
||||||
|
drafts.forEach(function (item) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "button is-light compose-draft-option";
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = String(item.label || "Option") + ": ";
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.textContent = String(item.text || "");
|
||||||
|
button.appendChild(strong);
|
||||||
|
button.appendChild(span);
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
textarea.value = String(item.text || "");
|
||||||
|
autosize();
|
||||||
|
textarea.focus();
|
||||||
|
hideAllCards();
|
||||||
|
});
|
||||||
|
container.appendChild(button);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setCardLoading(card, false);
|
||||||
|
card.querySelector(".compose-ai-content").textContent = "Failed to load drafts.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSummary = async function () {
|
||||||
|
const card = showCard("summary");
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCardLoading(card, true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(thread.dataset.summaryUrl + "?" + queryParams().toString(), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
setCardLoading(card, false);
|
||||||
|
if (!payload.ok) {
|
||||||
|
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load summary.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.querySelector(".compose-ai-content").textContent = String(payload.summary || "");
|
||||||
|
} catch (err) {
|
||||||
|
setCardLoading(card, false);
|
||||||
|
card.querySelector(".compose-ai-content").textContent = "Failed to load summary.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindEngageControls = function (card) {
|
||||||
|
const arm = card.querySelector(".engage-arm");
|
||||||
|
const confirm = card.querySelector(".engage-confirm");
|
||||||
|
const send = card.querySelector(".engage-send-btn");
|
||||||
|
const sync = function () {
|
||||||
|
send.disabled = !(arm.checked && confirm.checked && panelState.engageToken);
|
||||||
|
};
|
||||||
|
arm.addEventListener("change", sync);
|
||||||
|
confirm.addEventListener("change", sync);
|
||||||
|
send.addEventListener("click", async function () {
|
||||||
|
if (!panelState.engageToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.set("service", thread.dataset.service || "");
|
||||||
|
formData.set("identifier", thread.dataset.identifier || "");
|
||||||
|
if (thread.dataset.person) {
|
||||||
|
formData.set("person", thread.dataset.person);
|
||||||
|
}
|
||||||
|
formData.set("engage_token", panelState.engageToken);
|
||||||
|
formData.set("failsafe_arm", arm.checked ? "1" : "0");
|
||||||
|
formData.set("failsafe_confirm", confirm.checked ? "1" : "0");
|
||||||
|
try {
|
||||||
|
const response = await fetch(thread.dataset.engageSendUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json"
|
||||||
|
},
|
||||||
|
body: formData.toString()
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!payload.ok) {
|
||||||
|
setStatus(payload.error || "Engage send failed.", "danger");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(payload.message || "Shared engage sent.", "success");
|
||||||
|
hideAllCards();
|
||||||
|
poll(true);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("Engage send failed.", "danger");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEngage = async function () {
|
||||||
|
const card = showCard("engage");
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCardLoading(card, true);
|
||||||
|
panelState.engageToken = "";
|
||||||
|
const sendBtn = card.querySelector(".engage-send-btn");
|
||||||
|
const arm = card.querySelector(".engage-arm");
|
||||||
|
const confirm = card.querySelector(".engage-confirm");
|
||||||
|
arm.checked = false;
|
||||||
|
confirm.checked = false;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(thread.dataset.engagePreviewUrl + "?" + queryParams().toString(), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" }
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
setCardLoading(card, false);
|
||||||
|
if (!payload.ok) {
|
||||||
|
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load engage preview.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panelState.engageToken = String(payload.token || "");
|
||||||
|
let text = String(payload.preview || "");
|
||||||
|
if (payload.artifact) {
|
||||||
|
text = text + "\n\nSource: " + String(payload.artifact);
|
||||||
|
}
|
||||||
|
card.querySelector(".compose-ai-content").textContent = text;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
} catch (err) {
|
||||||
|
setCardLoading(card, false);
|
||||||
|
card.querySelector(".compose-ai-content").textContent = "Failed to load engage preview.";
|
||||||
|
}
|
||||||
|
if (!card.dataset.bound) {
|
||||||
|
bindEngageControls(card);
|
||||||
|
card.dataset.bound = "1";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
panel.querySelectorAll(".js-ai-trigger").forEach(function (button) {
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
const kind = button.dataset.kind;
|
||||||
|
if (panelState.activePanel === kind) {
|
||||||
|
hideAllCards();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "drafts") {
|
||||||
|
loadDrafts();
|
||||||
|
} else if (kind === "summary") {
|
||||||
|
loadSummary();
|
||||||
|
} else if (kind === "engage") {
|
||||||
|
loadEngage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panelState.docClickHandler = function (event) {
|
||||||
|
if (!panel.contains(event.target)) {
|
||||||
|
hideAllCards();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", panelState.docClickHandler);
|
||||||
|
|
||||||
textarea.addEventListener("keydown", function (event) {
|
textarea.addEventListener("keydown", function (event) {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (sendButton && sendButton.disabled) {
|
||||||
|
setStatus("Enable both send safety switches before sending.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -325,17 +807,23 @@
|
|||||||
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
document.body.addEventListener("composeMessageSent", panelState.eventHandler);
|
||||||
|
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
|
setupWebSocket();
|
||||||
panelState.timer = setInterval(function () {
|
panelState.timer = setInterval(function () {
|
||||||
if (!document.getElementById(panelId)) {
|
if (!document.getElementById(panelId)) {
|
||||||
clearInterval(panelState.timer);
|
clearInterval(panelState.timer);
|
||||||
document.body.removeEventListener(
|
document.body.removeEventListener("composeMessageSent", panelState.eventHandler);
|
||||||
"composeMessageSent",
|
document.removeEventListener("mousedown", panelState.docClickHandler);
|
||||||
panelState.eventHandler
|
if (panelState.socket) {
|
||||||
);
|
try {
|
||||||
|
panelState.socket.close();
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
delete window.giaComposePanels[panelId];
|
delete window.giaComposePanels[panelId];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
poll(false);
|
poll(false);
|
||||||
}, 1800);
|
}, 4000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
225
core/templates/partials/osint/list-table.html
Normal file
225
core/templates/partials/osint/list-table.html
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
{% include 'mixins/partials/notify.html' %}
|
||||||
|
<div
|
||||||
|
id="{{ osint_table_id }}"
|
||||||
|
class="osint-table-shell"
|
||||||
|
hx-get="{{ osint_refresh_url }}"
|
||||||
|
hx-target="#{{ osint_table_id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% if osint_event_name %}hx-trigger="{{ osint_event_name }} from:body"{% endif %}>
|
||||||
|
|
||||||
|
{% if osint_show_search %}
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
action="{{ osint_search_url }}"
|
||||||
|
class="osint-table-toolbar"
|
||||||
|
hx-get="{{ osint_search_url }}"
|
||||||
|
hx-target="#{{ osint_table_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="field has-addons is-flex-wrap-wrap">
|
||||||
|
<div class="control is-expanded" style="min-width: 14rem;">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value="{{ osint_search_query }}"
|
||||||
|
placeholder="Search {{ osint_title|lower }}...">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select name="field">
|
||||||
|
{% for field in osint_search_fields %}
|
||||||
|
<option
|
||||||
|
value="{{ field.value }}"
|
||||||
|
{% if field.value == osint_search_field %}selected{% endif %}>
|
||||||
|
{{ field.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-link is-light" type="submit">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-light" href="{{ osint_search_url }}">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-container osint-results-table-wrap">
|
||||||
|
<table class="table is-fullwidth is-hoverable osint-results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for column in osint_columns %}
|
||||||
|
<th>
|
||||||
|
{% if column.sortable %}
|
||||||
|
<a
|
||||||
|
class="osint-sort-link"
|
||||||
|
href="{{ column.sort_url }}"
|
||||||
|
hx-get="{{ column.sort_url }}"
|
||||||
|
hx-target="#{{ osint_table_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<span>{{ column.label }}</span>
|
||||||
|
<span class="icon is-small">
|
||||||
|
{% if column.is_sorted and column.is_desc %}
|
||||||
|
<i class="fa-solid fa-sort-down"></i>
|
||||||
|
{% elif column.is_sorted %}
|
||||||
|
<i class="fa-solid fa-sort-up"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-sort"></i>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{{ column.label }}
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
{% if osint_show_actions %}<th>Actions</th>{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in osint_rows %}
|
||||||
|
<tr>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td>
|
||||||
|
{% if cell.kind == "id_copy" %}
|
||||||
|
<a
|
||||||
|
class="button is-small has-text-grey"
|
||||||
|
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ cell.value }}');">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-copy"></i>
|
||||||
|
</span>
|
||||||
|
<span>{{ cell.value }}</span>
|
||||||
|
</a>
|
||||||
|
{% elif cell.kind == "bool" %}
|
||||||
|
{% if cell.value %}
|
||||||
|
<span class="icon has-text-success">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="icon has-text-grey">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif cell.kind == "datetime" %}
|
||||||
|
{% if cell.value %}
|
||||||
|
{{ cell.value|date:"M j, Y P" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="has-text-grey">-</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if cell.value or cell.value == 0 %}
|
||||||
|
{{ cell.value }}
|
||||||
|
{% else %}
|
||||||
|
<span class="has-text-grey">-</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
{% if osint_show_actions %}
|
||||||
|
<td>
|
||||||
|
<div class="buttons are-small">
|
||||||
|
{% for action in row.actions %}
|
||||||
|
{% if action.mode == "hx-get" %}
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-get="{{ action.url }}"
|
||||||
|
hx-target="{{ action.target }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
title="{{ action.title }}">
|
||||||
|
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
||||||
|
</button>
|
||||||
|
{% elif action.mode == "hx-delete" %}
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
hx-delete="{{ action.url }}"
|
||||||
|
hx-target="{{ action.target }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
{% if action.confirm %}hx-confirm="{{ action.confirm }}"{% endif %}
|
||||||
|
title="{{ action.title }}">
|
||||||
|
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
||||||
|
</button>
|
||||||
|
{% elif action.mode == "link" %}
|
||||||
|
<a class="button" href="{{ action.url }}" title="{{ action.title }}">
|
||||||
|
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{% if osint_show_actions %}{{ osint_columns|length|add:'1' }}{% else %}{{ osint_columns|length }}{% endif %}">
|
||||||
|
<p class="has-text-grey">No results found.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if osint_pagination.enabled %}
|
||||||
|
<nav class="pagination is-small" role="navigation" aria-label="pagination">
|
||||||
|
{% if osint_pagination.has_previous %}
|
||||||
|
<a
|
||||||
|
class="pagination-previous"
|
||||||
|
href="{{ osint_pagination.previous_url }}"
|
||||||
|
hx-get="{{ osint_pagination.previous_url }}"
|
||||||
|
hx-target="#{{ osint_table_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="pagination-previous is-disabled">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if osint_pagination.has_next %}
|
||||||
|
<a
|
||||||
|
class="pagination-next"
|
||||||
|
href="{{ osint_pagination.next_url }}"
|
||||||
|
hx-get="{{ osint_pagination.next_url }}"
|
||||||
|
hx-target="#{{ osint_table_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="pagination-next is-disabled">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<ul class="pagination-list">
|
||||||
|
{% for page in osint_pagination.pages %}
|
||||||
|
{% if page.ellipsis %}
|
||||||
|
<li><span class="pagination-ellipsis">…</span></li>
|
||||||
|
{% elif page.current %}
|
||||||
|
<li><a class="pagination-link is-current">{{ page.number }}</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="pagination-link"
|
||||||
|
href="{{ page.url }}"
|
||||||
|
hx-get="{{ page.url }}"
|
||||||
|
hx-target="#{{ osint_table_id }}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{{ page.number }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="help has-text-grey">
|
||||||
|
{{ osint_result_count }} result{% if osint_result_count != 1 %}s{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
78
core/templates/partials/osint/search-panel.html
Normal file
78
core/templates/partials/osint/search-panel.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<div class="box">
|
||||||
|
<form
|
||||||
|
class="osint-search-form"
|
||||||
|
method="get"
|
||||||
|
action="{{ osint_search_url }}"
|
||||||
|
hx-get="{{ osint_search_url }}"
|
||||||
|
hx-target="#osint-search-results"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="columns is-multiline">
|
||||||
|
<div class="column is-4">
|
||||||
|
<label class="label">Scope</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="scope">
|
||||||
|
{% for option in scope_options %}
|
||||||
|
<option
|
||||||
|
value="{{ option.value }}"
|
||||||
|
{% if option.value == selected_scope %}selected{% endif %}>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
|
<label class="label">Field</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="field">
|
||||||
|
<option value="__all__" {% if selected_field == "__all__" %}selected{% endif %}>
|
||||||
|
All Fields
|
||||||
|
</option>
|
||||||
|
{% for option in field_options %}
|
||||||
|
<option
|
||||||
|
value="{{ option.value }}"
|
||||||
|
{% if option.value == selected_field %}selected{% endif %}>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-4">
|
||||||
|
<label class="label">Rows Per Page</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="per_page">
|
||||||
|
<option value="10" {% if selected_per_page == 10 %}selected{% endif %}>10</option>
|
||||||
|
<option value="20" {% if selected_per_page == 20 %}selected{% endif %}>20</option>
|
||||||
|
<option value="50" {% if selected_per_page == 50 %}selected{% endif %}>50</option>
|
||||||
|
<option value="100" {% if selected_per_page == 100 %}selected{% endif %}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-9">
|
||||||
|
<label class="label">Search Query</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value="{{ search_query }}"
|
||||||
|
placeholder="Search text, values, or relation names...">
|
||||||
|
</div>
|
||||||
|
<div class="column is-3">
|
||||||
|
<label class="label"> </label>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button is-link is-light is-fullwidth" type="submit">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<a class="button is-light is-fullwidth" href="{{ osint_search_url }}">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="osint-search-results">
|
||||||
|
{% include "partials/results_table.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1
core/templates/partials/results_table.html
Normal file
1
core/templates/partials/results_table.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{% include "partials/osint/list-table.html" %}
|
||||||
14
core/templates/partials/whatsapp-account-add.html
Normal file
14
core/templates/partials/whatsapp-account-add.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% if object.ok %}
|
||||||
|
<img src="data:image/png;base64, {{ object.image_b64 }}" alt="WhatsApp QR code" />
|
||||||
|
{% if object.warning %}
|
||||||
|
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<article class="notification is-warning is-light" style="margin-bottom: 0;">
|
||||||
|
<p><strong>WhatsApp QR Not Ready.</strong></p>
|
||||||
|
<p>{{ object.error|default:"No Neonize pairing QR is available yet." }}</p>
|
||||||
|
{% if object.warning %}
|
||||||
|
<p style="margin-top: 0.45rem;">{{ object.warning }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone as dt_timezone
|
from datetime import datetime, timezone as dt_timezone
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core import signing
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponseBadRequest, JsonResponse
|
from django.http import HttpResponseBadRequest, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -14,7 +17,21 @@ from django.utils import timezone as dj_timezone
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
from core.models import ChatSession, Message, Person, PersonIdentifier
|
from core.messaging import ai as ai_runner
|
||||||
|
from core.messaging.utils import messages_to_string
|
||||||
|
from core.models import (
|
||||||
|
AI,
|
||||||
|
ChatSession,
|
||||||
|
Message,
|
||||||
|
PatternMitigationPlan,
|
||||||
|
Person,
|
||||||
|
PersonIdentifier,
|
||||||
|
)
|
||||||
|
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
||||||
|
|
||||||
|
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||||
|
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||||
|
COMPOSE_AI_CACHE_TTL = 60 * 30
|
||||||
|
|
||||||
|
|
||||||
def _default_service(service: str | None) -> str:
|
def _default_service(service: str | None) -> str:
|
||||||
@@ -64,6 +81,185 @@ def _serialize_message(msg: Message) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_name(user) -> str:
|
||||||
|
return (
|
||||||
|
user.first_name
|
||||||
|
or user.get_full_name().strip()
|
||||||
|
or user.username
|
||||||
|
or "Me"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_ws_token(user_id, service, identifier, person_id):
|
||||||
|
payload = {
|
||||||
|
"u": int(user_id),
|
||||||
|
"s": str(service or ""),
|
||||||
|
"i": str(identifier or ""),
|
||||||
|
"p": str(person_id) if person_id else "",
|
||||||
|
"exp": int(time.time()) + (60 * 60 * 12),
|
||||||
|
}
|
||||||
|
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
|
||||||
|
raw = "|".join(
|
||||||
|
[
|
||||||
|
str(kind or ""),
|
||||||
|
str(user_id),
|
||||||
|
str(service or ""),
|
||||||
|
str(identifier or ""),
|
||||||
|
str(person_id or ""),
|
||||||
|
str(last_ts or 0),
|
||||||
|
str(limit or 0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
return f"compose:{kind}:{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_text(value):
|
||||||
|
cleaned = re.sub(r"\s+", " ", str(value or "").strip())
|
||||||
|
cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned)
|
||||||
|
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
|
||||||
|
cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
|
||||||
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _engage_body_only(value):
|
||||||
|
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||||
|
if lines and lines[0].startswith("**"):
|
||||||
|
lines = lines[1:]
|
||||||
|
if lines and lines[0].lower() == "guidance:":
|
||||||
|
lines = lines[1:]
|
||||||
|
return _plain_text(" ".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _messages_for_ai(user, person_identifier, limit):
|
||||||
|
if person_identifier is None:
|
||||||
|
return []
|
||||||
|
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
|
||||||
|
rows = list(
|
||||||
|
Message.objects.filter(user=user, session=session)
|
||||||
|
.select_related("session", "session__identifier", "session__identifier__person")
|
||||||
|
.order_by("-ts")[:limit]
|
||||||
|
)
|
||||||
|
rows.reverse()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_drafts():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": "Soft",
|
||||||
|
"text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Neutral",
|
||||||
|
"text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Firm",
|
||||||
|
"text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_draft_prompt(owner_name, person_name, transcript):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Generate exactly three short reply drafts for a chat. "
|
||||||
|
"Return labels Soft, Neutral, Firm. "
|
||||||
|
"Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n"
|
||||||
|
"Each draft must be one to two sentences, plain text, no markdown."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Me: {owner_name}\n"
|
||||||
|
f"Other: {person_name}\n"
|
||||||
|
f"Conversation:\n{transcript}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary_prompt(owner_name, person_name, transcript):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Create a concise conversation summary with three sections. "
|
||||||
|
"Use this exact structure:\n"
|
||||||
|
"Headlines:\n- ...\n"
|
||||||
|
"Patterns:\n- ...\n"
|
||||||
|
"Suggested Next Message:\n- ...\n"
|
||||||
|
"Keep each bullet practical and specific."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Me: {owner_name}\n"
|
||||||
|
f"Other: {person_name}\n"
|
||||||
|
f"Conversation:\n{transcript}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_engage_prompt(owner_name, person_name, transcript):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Write one short de-escalating outreach in shared framing. "
|
||||||
|
"Use 'we/us/our' only. No names. One or two sentences."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Me: {owner_name}\n"
|
||||||
|
f"Other: {person_name}\n"
|
||||||
|
f"Conversation:\n{transcript}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_plan_for_person(user, person):
|
||||||
|
if person is None:
|
||||||
|
return None
|
||||||
|
conversation = (
|
||||||
|
PatternMitigationPlan.objects.filter(
|
||||||
|
user=user,
|
||||||
|
conversation__participants=person,
|
||||||
|
)
|
||||||
|
.select_related("conversation")
|
||||||
|
.order_by("-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
def _best_engage_source(plan):
|
||||||
|
if plan is None:
|
||||||
|
return (None, "")
|
||||||
|
correction = plan.corrections.order_by("-created_at").first()
|
||||||
|
if correction:
|
||||||
|
return (correction, "correction")
|
||||||
|
rule = plan.rules.order_by("-created_at").first()
|
||||||
|
if rule:
|
||||||
|
return (rule, "rule")
|
||||||
|
game = plan.games.order_by("-created_at").first()
|
||||||
|
if game:
|
||||||
|
return (game, "game")
|
||||||
|
return (None, "")
|
||||||
|
|
||||||
|
|
||||||
def _context_base(user, service, identifier, person):
|
def _context_base(user, service, identifier, person):
|
||||||
person_identifier = None
|
person_identifier = None
|
||||||
if person is not None:
|
if person is not None:
|
||||||
@@ -143,6 +339,13 @@ def _panel_context(
|
|||||||
base["identifier"],
|
base["identifier"],
|
||||||
base["person"].id if base["person"] else None,
|
base["person"].id if base["person"] else None,
|
||||||
)
|
)
|
||||||
|
ws_token = _compose_ws_token(
|
||||||
|
user_id=request.user.id,
|
||||||
|
service=base["service"],
|
||||||
|
identifier=base["identifier"],
|
||||||
|
person_id=base["person"].id if base["person"] else None,
|
||||||
|
)
|
||||||
|
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
|
||||||
|
|
||||||
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
|
||||||
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
|
||||||
@@ -164,6 +367,11 @@ def _panel_context(
|
|||||||
"render_mode": render_mode,
|
"render_mode": render_mode,
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_drafts_url": reverse("compose_drafts"),
|
||||||
|
"compose_summary_url": reverse("compose_summary"),
|
||||||
|
"compose_engage_preview_url": reverse("compose_engage_preview"),
|
||||||
|
"compose_engage_send_url": reverse("compose_engage_send"),
|
||||||
|
"compose_ws_url": ws_url,
|
||||||
"ai_workspace_url": (
|
"ai_workspace_url": (
|
||||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||||
if base["person"]
|
if base["person"]
|
||||||
@@ -305,6 +513,285 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
return JsonResponse(payload)
|
return JsonResponse(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeDrafts(LoginRequiredMixin, View):
|
||||||
|
def get(self, request):
|
||||||
|
service = _default_service(request.GET.get("service"))
|
||||||
|
identifier = str(request.GET.get("identifier") or "").strip()
|
||||||
|
person = None
|
||||||
|
person_id = request.GET.get("person")
|
||||||
|
if person_id:
|
||||||
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||||
|
if not identifier and person is None:
|
||||||
|
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
|
||||||
|
|
||||||
|
base = _context_base(request.user, service, identifier, person)
|
||||||
|
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||||
|
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
|
||||||
|
if not messages:
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"cached": False,
|
||||||
|
"drafts": _fallback_drafts(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
last_ts = int(messages[-1].ts or 0)
|
||||||
|
cache_key = _compose_ai_cache_key(
|
||||||
|
"drafts",
|
||||||
|
request.user.id,
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
base["person"].id if base["person"] else "",
|
||||||
|
last_ts,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return JsonResponse({"ok": True, "cached": True, "drafts": cached})
|
||||||
|
|
||||||
|
ai_obj = AI.objects.filter(user=request.user).first()
|
||||||
|
transcript = messages_to_string(
|
||||||
|
messages,
|
||||||
|
author_rewrites={
|
||||||
|
"USER": _owner_name(request.user),
|
||||||
|
"BOT": "Assistant",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
drafts = _fallback_drafts()
|
||||||
|
if ai_obj is not None:
|
||||||
|
try:
|
||||||
|
result = async_to_sync(ai_runner.run_prompt)(
|
||||||
|
_build_draft_prompt(
|
||||||
|
owner_name=_owner_name(request.user),
|
||||||
|
person_name=base["person"].name if base["person"] else "Other",
|
||||||
|
transcript=transcript,
|
||||||
|
),
|
||||||
|
ai_obj,
|
||||||
|
)
|
||||||
|
parsed = _parse_draft_options(result)
|
||||||
|
if parsed:
|
||||||
|
drafts = parsed
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL)
|
||||||
|
return JsonResponse({"ok": True, "cached": False, "drafts": drafts})
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeSummary(LoginRequiredMixin, View):
|
||||||
|
def get(self, request):
|
||||||
|
service = _default_service(request.GET.get("service"))
|
||||||
|
identifier = str(request.GET.get("identifier") or "").strip()
|
||||||
|
person = None
|
||||||
|
person_id = request.GET.get("person")
|
||||||
|
if person_id:
|
||||||
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||||
|
if not identifier and person is None:
|
||||||
|
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
|
||||||
|
|
||||||
|
base = _context_base(request.user, service, identifier, person)
|
||||||
|
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||||
|
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
|
||||||
|
if not messages:
|
||||||
|
return JsonResponse({"ok": True, "cached": False, "summary": ""})
|
||||||
|
|
||||||
|
last_ts = int(messages[-1].ts or 0)
|
||||||
|
cache_key = _compose_ai_cache_key(
|
||||||
|
"summary",
|
||||||
|
request.user.id,
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
base["person"].id if base["person"] else "",
|
||||||
|
last_ts,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return JsonResponse({"ok": True, "cached": True, "summary": cached})
|
||||||
|
|
||||||
|
ai_obj = AI.objects.filter(user=request.user).first()
|
||||||
|
transcript = messages_to_string(
|
||||||
|
messages,
|
||||||
|
author_rewrites={
|
||||||
|
"USER": _owner_name(request.user),
|
||||||
|
"BOT": "Assistant",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ai_obj is None:
|
||||||
|
fallback = (
|
||||||
|
"Headlines:\n"
|
||||||
|
"- Conversation loaded.\n"
|
||||||
|
"Patterns:\n"
|
||||||
|
"- Not enough AI context configured yet.\n"
|
||||||
|
"Suggested Next Message:\n"
|
||||||
|
"- I want us to keep this clear and constructive."
|
||||||
|
)
|
||||||
|
cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL)
|
||||||
|
return JsonResponse({"ok": True, "cached": False, "summary": fallback})
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = async_to_sync(ai_runner.run_prompt)(
|
||||||
|
_build_summary_prompt(
|
||||||
|
owner_name=_owner_name(request.user),
|
||||||
|
person_name=base["person"].name if base["person"] else "Other",
|
||||||
|
transcript=transcript,
|
||||||
|
),
|
||||||
|
ai_obj,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return JsonResponse({"ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
summary = str(summary or "").strip()
|
||||||
|
cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL)
|
||||||
|
return JsonResponse({"ok": True, "cached": False, "summary": summary})
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeEngagePreview(LoginRequiredMixin, View):
|
||||||
|
def get(self, request):
|
||||||
|
service = _default_service(request.GET.get("service"))
|
||||||
|
identifier = str(request.GET.get("identifier") or "").strip()
|
||||||
|
person = None
|
||||||
|
person_id = request.GET.get("person")
|
||||||
|
if person_id:
|
||||||
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||||
|
if not identifier and person is None:
|
||||||
|
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
|
||||||
|
|
||||||
|
base = _context_base(request.user, service, identifier, person)
|
||||||
|
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||||
|
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
|
||||||
|
transcript = messages_to_string(
|
||||||
|
messages,
|
||||||
|
author_rewrites={
|
||||||
|
"USER": _owner_name(request.user),
|
||||||
|
"BOT": "Assistant",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
owner_name = _owner_name(request.user)
|
||||||
|
recipient_name = base["person"].name if base["person"] else "Other"
|
||||||
|
plan = _latest_plan_for_person(request.user, base["person"])
|
||||||
|
source_obj, source_kind = _best_engage_source(plan)
|
||||||
|
|
||||||
|
preview = ""
|
||||||
|
outbound = ""
|
||||||
|
artifact_label = "AI-generated"
|
||||||
|
if source_obj is not None:
|
||||||
|
payload = _build_engage_payload(
|
||||||
|
source_obj=source_obj,
|
||||||
|
source_kind=source_kind,
|
||||||
|
share_target="other",
|
||||||
|
framing="shared",
|
||||||
|
context_note="",
|
||||||
|
owner_name=owner_name,
|
||||||
|
recipient_name=recipient_name,
|
||||||
|
)
|
||||||
|
preview = str(payload.get("preview") or "").strip()
|
||||||
|
outbound = _engage_body_only(payload.get("outbound") or "")
|
||||||
|
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
|
||||||
|
else:
|
||||||
|
ai_obj = AI.objects.filter(user=request.user).first()
|
||||||
|
if ai_obj is not None:
|
||||||
|
try:
|
||||||
|
generated = async_to_sync(ai_runner.run_prompt)(
|
||||||
|
_build_engage_prompt(owner_name, recipient_name, transcript),
|
||||||
|
ai_obj,
|
||||||
|
)
|
||||||
|
outbound = _plain_text(generated)
|
||||||
|
except Exception:
|
||||||
|
outbound = ""
|
||||||
|
if not outbound:
|
||||||
|
outbound = (
|
||||||
|
"We should slow down, clarify what we mean, and respond with care."
|
||||||
|
)
|
||||||
|
preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}"
|
||||||
|
|
||||||
|
token = signing.dumps(
|
||||||
|
{
|
||||||
|
"u": request.user.id,
|
||||||
|
"s": base["service"],
|
||||||
|
"i": base["identifier"],
|
||||||
|
"p": str(base["person"].id) if base["person"] else "",
|
||||||
|
"outbound": outbound,
|
||||||
|
"exp": int(time.time()) + (60 * 10),
|
||||||
|
},
|
||||||
|
salt=COMPOSE_ENGAGE_TOKEN_SALT,
|
||||||
|
)
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"preview": preview,
|
||||||
|
"outbound": outbound,
|
||||||
|
"token": token,
|
||||||
|
"artifact": artifact_label,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeEngageSend(LoginRequiredMixin, View):
|
||||||
|
def post(self, request):
|
||||||
|
service = _default_service(request.POST.get("service"))
|
||||||
|
identifier = str(request.POST.get("identifier") or "").strip()
|
||||||
|
person = None
|
||||||
|
person_id = request.POST.get("person")
|
||||||
|
if person_id:
|
||||||
|
person = get_object_or_404(Person, id=person_id, user=request.user)
|
||||||
|
if not identifier and person is None:
|
||||||
|
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
|
||||||
|
|
||||||
|
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
|
||||||
|
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
||||||
|
if failsafe_arm != "1" or failsafe_confirm != "1":
|
||||||
|
return JsonResponse(
|
||||||
|
{"ok": False, "error": "Enable both send safety switches first."}
|
||||||
|
)
|
||||||
|
|
||||||
|
token = str(request.POST.get("engage_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
return JsonResponse({"ok": False, "error": "Missing engage token."})
|
||||||
|
try:
|
||||||
|
payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT)
|
||||||
|
except Exception:
|
||||||
|
return JsonResponse({"ok": False, "error": "Invalid engage token."})
|
||||||
|
|
||||||
|
if int(payload.get("u") or 0) != int(request.user.id):
|
||||||
|
return JsonResponse({"ok": False, "error": "Token does not match user."})
|
||||||
|
if int(payload.get("exp") or 0) < int(time.time()):
|
||||||
|
return JsonResponse({"ok": False, "error": "Engage token expired."})
|
||||||
|
outbound = str(payload.get("outbound") or "").strip()
|
||||||
|
if not outbound:
|
||||||
|
return JsonResponse({"ok": False, "error": "Empty engage payload."})
|
||||||
|
|
||||||
|
base = _context_base(request.user, service, identifier, person)
|
||||||
|
ts = async_to_sync(transport.send_message_raw)(
|
||||||
|
base["service"],
|
||||||
|
base["identifier"],
|
||||||
|
text=outbound,
|
||||||
|
attachments=[],
|
||||||
|
)
|
||||||
|
if not ts:
|
||||||
|
return JsonResponse({"ok": False, "error": "Send failed."})
|
||||||
|
|
||||||
|
if base["person_identifier"] is not None:
|
||||||
|
session, _ = ChatSession.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
identifier=base["person_identifier"],
|
||||||
|
)
|
||||||
|
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
|
||||||
|
Message.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
session=session,
|
||||||
|
sender_uuid="",
|
||||||
|
text=outbound,
|
||||||
|
ts=ts_value,
|
||||||
|
delivered_ts=ts_value if str(ts).isdigit() else None,
|
||||||
|
custom_author="USER",
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({"ok": True, "message": "Shared engage sent."})
|
||||||
|
|
||||||
|
|
||||||
class ComposeSend(LoginRequiredMixin, View):
|
class ComposeSend(LoginRequiredMixin, View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
service = _default_service(request.POST.get("service"))
|
service = _default_service(request.POST.get("service"))
|
||||||
@@ -320,6 +807,18 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
if not identifier and person is None:
|
if not identifier and person is None:
|
||||||
return HttpResponseBadRequest("Missing contact identifier.")
|
return HttpResponseBadRequest("Missing contact identifier.")
|
||||||
|
|
||||||
|
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
|
||||||
|
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
|
||||||
|
if failsafe_arm != "1" or failsafe_confirm != "1":
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"partials/compose-send-status.html",
|
||||||
|
{
|
||||||
|
"notice_message": "Enable both send safety switches before sending.",
|
||||||
|
"notice_level": "warning",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
text = str(request.POST.get("text") or "").strip()
|
text = str(request.POST.get("text") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return render(
|
return render(
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
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, ObjectUpdate
|
||||||
|
|
||||||
from core.forms import GroupForm
|
from core.forms import GroupForm
|
||||||
from core.models import Group
|
from core.models import Group
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GroupList(LoginRequiredMixin, ObjectList):
|
class GroupList(LoginRequiredMixin, OSINTListBase):
|
||||||
list_template = "partials/group-list.html"
|
osint_scope = "groups"
|
||||||
model = Group
|
model = Group
|
||||||
page_title = "Groups"
|
page_title = "Groups"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
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, ObjectUpdate
|
||||||
|
|
||||||
from core.forms import ManipulationForm
|
from core.forms import ManipulationForm
|
||||||
from core.models import Manipulation
|
from core.models import Manipulation
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ManipulationList(LoginRequiredMixin, ObjectList):
|
class ManipulationList(LoginRequiredMixin, OSINTListBase):
|
||||||
list_template = "partials/manipulation-list.html"
|
osint_scope = "manipulations"
|
||||||
model = Manipulation
|
model = Manipulation
|
||||||
page_title = "Manipulations"
|
page_title = "Manipulations"
|
||||||
|
|
||||||
|
|||||||
1013
core/views/osint.py
Normal file
1013
core/views/osint.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
|||||||
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, ObjectUpdate
|
||||||
|
|
||||||
from core.forms import PersonForm
|
from core.forms import PersonForm
|
||||||
from core.models import Person
|
from core.models import Person
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PersonList(LoginRequiredMixin, ObjectList):
|
class PersonList(LoginRequiredMixin, OSINTListBase):
|
||||||
list_template = "partials/person-list.html"
|
osint_scope = "people"
|
||||||
model = Person
|
model = Person
|
||||||
page_title = "People"
|
page_title = "People"
|
||||||
# page_subtitle = "Add times here in order to permit trading."
|
|
||||||
|
|
||||||
list_url_name = "people"
|
list_url_name = "people"
|
||||||
list_url_args = ["type"]
|
list_url_args = ["type"]
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
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, ObjectUpdate
|
||||||
|
|
||||||
from core.forms import PersonaForm
|
from core.forms import PersonaForm
|
||||||
from core.models import Persona
|
from core.models import Persona
|
||||||
|
from core.views.osint import OSINTListBase
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PersonaList(LoginRequiredMixin, ObjectList):
|
class PersonaList(LoginRequiredMixin, OSINTListBase):
|
||||||
list_template = "partials/persona-list.html"
|
osint_scope = "personas"
|
||||||
model = Persona
|
model = Persona
|
||||||
page_title = "Personas"
|
page_title = "Personas"
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,94 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views import View
|
||||||
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
|
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||||
|
|
||||||
|
|
||||||
class WhatsApp(Signal):
|
class WhatsApp(SuperUserRequiredMixin, View):
|
||||||
|
template_name = "pages/signal.html"
|
||||||
service = "whatsapp"
|
service = "whatsapp"
|
||||||
page_title = "WhatsApp"
|
page_title = "WhatsApp"
|
||||||
accounts_url_name = "whatsapp_accounts"
|
accounts_url_name = "whatsapp_accounts"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"service": self.service,
|
||||||
|
"service_label": self.page_title,
|
||||||
|
"accounts_url_name": self.accounts_url_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
class WhatsAppAccounts(SignalAccounts):
|
|
||||||
|
class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
||||||
|
list_template = "partials/signal-accounts.html"
|
||||||
service = "whatsapp"
|
service = "whatsapp"
|
||||||
|
|
||||||
context_object_name_singular = "WhatsApp Account"
|
context_object_name_singular = "WhatsApp Account"
|
||||||
context_object_name = "WhatsApp Accounts"
|
context_object_name = "WhatsApp Accounts"
|
||||||
list_url_name = "whatsapp_accounts"
|
list_url_name = "whatsapp_accounts"
|
||||||
|
list_url_args = ["type"]
|
||||||
|
|
||||||
|
def _normalize_accounts(self, rows):
|
||||||
|
out = []
|
||||||
|
for item in rows or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
value = (
|
||||||
|
item.get("number")
|
||||||
|
or item.get("id")
|
||||||
|
or item.get("jid")
|
||||||
|
or item.get("account")
|
||||||
|
)
|
||||||
|
if value:
|
||||||
|
out.append(str(value))
|
||||||
|
elif item:
|
||||||
|
out.append(str(item))
|
||||||
|
return out
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
self.extra_context = self._service_context(
|
self.extra_context = {
|
||||||
service="whatsapp",
|
"service": "whatsapp",
|
||||||
label="WhatsApp",
|
"service_label": "WhatsApp",
|
||||||
add_url_name="whatsapp_account_add",
|
"account_add_url_name": "whatsapp_account_add",
|
||||||
show_contact_actions=False,
|
"show_contact_actions": False,
|
||||||
)
|
"endpoint_base": str(
|
||||||
|
getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080")
|
||||||
|
).rstrip("/"),
|
||||||
|
"service_warning": transport.get_service_warning("whatsapp"),
|
||||||
|
}
|
||||||
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppAccountAdd(SignalAccountAdd):
|
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||||
|
detail_template = "partials/whatsapp-account-add.html"
|
||||||
service = "whatsapp"
|
service = "whatsapp"
|
||||||
|
context_object_name_singular = "Add Account"
|
||||||
|
context_object_name = "Add Account"
|
||||||
detail_url_name = "whatsapp_account_add"
|
detail_url_name = "whatsapp_account_add"
|
||||||
|
detail_url_args = ["type", "device"]
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_object(self, **kwargs):
|
||||||
|
form_args = self.request.POST.dict()
|
||||||
|
device_name = form_args.get("device", "GIA Device")
|
||||||
|
try:
|
||||||
|
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"image_b64": transport.image_bytes_to_base64(image_bytes),
|
||||||
|
"warning": transport.get_service_warning(self.service),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": str(exc),
|
||||||
|
"warning": transport.get_service_warning(self.service),
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,6 +155,55 @@ INSIGHT_METRICS = {
|
|||||||
"values often precede misunderstandings or withdrawal cycles."
|
"values often precede misunderstandings or withdrawal cycles."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"reciprocity_score": {
|
||||||
|
"title": "Reciprocity Component",
|
||||||
|
"group": "stability",
|
||||||
|
"history_field": "reciprocity_score",
|
||||||
|
"calculation": (
|
||||||
|
"100 * (1 - |inbound - outbound| / total_messages). Higher means "
|
||||||
|
"more balanced participation."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"Lower reciprocity can reflect perceived asymmetry and rising pursuit/"
|
||||||
|
"withdraw cycles."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"continuity_score": {
|
||||||
|
"title": "Continuity Component",
|
||||||
|
"group": "stability",
|
||||||
|
"history_field": "continuity_score",
|
||||||
|
"calculation": (
|
||||||
|
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
|
||||||
|
"day-to-day continuity."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"Drops can signal communication becoming episodic or reactive."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"response_score": {
|
||||||
|
"title": "Response Component",
|
||||||
|
"group": "stability",
|
||||||
|
"history_field": "response_score",
|
||||||
|
"calculation": (
|
||||||
|
"Average of inbound and outbound response-lag scores, each mapped from "
|
||||||
|
"median lag to a 0-100 curve."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"Lower response score can indicate delayed repair loops during tension."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"volatility_score": {
|
||||||
|
"title": "Volatility Component",
|
||||||
|
"group": "stability",
|
||||||
|
"history_field": "volatility_score",
|
||||||
|
"calculation": (
|
||||||
|
"Derived from coefficient of variation of daily message counts and "
|
||||||
|
"inverted to a 0-100 stability signal."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"High volatility can suggest inconsistent rhythm and reduced predictability."
|
||||||
|
),
|
||||||
|
},
|
||||||
"stability_confidence": {
|
"stability_confidence": {
|
||||||
"title": "Stability Confidence",
|
"title": "Stability Confidence",
|
||||||
"group": "confidence",
|
"group": "confidence",
|
||||||
@@ -219,6 +268,52 @@ INSIGHT_METRICS = {
|
|||||||
"Estimates user follow-through and consistency toward the counterpart."
|
"Estimates user follow-through and consistency toward the counterpart."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"inbound_response_score": {
|
||||||
|
"title": "Inbound Response Score",
|
||||||
|
"group": "commitment",
|
||||||
|
"history_field": "inbound_response_score",
|
||||||
|
"calculation": (
|
||||||
|
"Response-speed score built from median lag between user outbound and "
|
||||||
|
"counterpart inbound replies."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"Lower values suggest delayed reciprocity from counterpart direction."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"outbound_response_score": {
|
||||||
|
"title": "Outbound Response Score",
|
||||||
|
"group": "commitment",
|
||||||
|
"history_field": "outbound_response_score",
|
||||||
|
"calculation": (
|
||||||
|
"Response-speed score built from median lag between counterpart inbound "
|
||||||
|
"and user outbound replies."
|
||||||
|
),
|
||||||
|
"psychology": "Lower values suggest slower follow-through from user direction.",
|
||||||
|
},
|
||||||
|
"balance_inbound_score": {
|
||||||
|
"title": "Inbound Balance Score",
|
||||||
|
"group": "commitment",
|
||||||
|
"history_field": "balance_inbound_score",
|
||||||
|
"calculation": (
|
||||||
|
"100 * min(1, inbound_messages / outbound_messages). Captures inbound "
|
||||||
|
"participation parity."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"Lower values can indicate one-sided conversational load from user side."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"balance_outbound_score": {
|
||||||
|
"title": "Outbound Balance Score",
|
||||||
|
"group": "commitment",
|
||||||
|
"history_field": "balance_outbound_score",
|
||||||
|
"calculation": (
|
||||||
|
"100 * min(1, outbound_messages / inbound_messages). Captures outbound "
|
||||||
|
"participation parity."
|
||||||
|
),
|
||||||
|
"psychology": (
|
||||||
|
"Lower values can indicate one-sided conversational load from counterpart side."
|
||||||
|
),
|
||||||
|
},
|
||||||
"commitment_confidence": {
|
"commitment_confidence": {
|
||||||
"title": "Commit Confidence",
|
"title": "Commit Confidence",
|
||||||
"group": "confidence",
|
"group": "confidence",
|
||||||
@@ -334,6 +429,78 @@ INSIGHT_GRAPH_SPECS = [
|
|||||||
"y_min": 0,
|
"y_min": 0,
|
||||||
"y_max": None,
|
"y_max": None,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slug": "reciprocity_score",
|
||||||
|
"title": "Reciprocity Component",
|
||||||
|
"field": "reciprocity_score",
|
||||||
|
"group": "stability",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "continuity_score",
|
||||||
|
"title": "Continuity Component",
|
||||||
|
"field": "continuity_score",
|
||||||
|
"group": "stability",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "response_score",
|
||||||
|
"title": "Response Component",
|
||||||
|
"field": "response_score",
|
||||||
|
"group": "stability",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "volatility_score",
|
||||||
|
"title": "Volatility Component",
|
||||||
|
"field": "volatility_score",
|
||||||
|
"group": "stability",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "inbound_response_score",
|
||||||
|
"title": "Inbound Response Score",
|
||||||
|
"field": "inbound_response_score",
|
||||||
|
"group": "commitment",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "outbound_response_score",
|
||||||
|
"title": "Outbound Response Score",
|
||||||
|
"field": "outbound_response_score",
|
||||||
|
"group": "commitment",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "balance_inbound_score",
|
||||||
|
"title": "Inbound Balance Score",
|
||||||
|
"field": "balance_inbound_score",
|
||||||
|
"group": "commitment",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "balance_outbound_score",
|
||||||
|
"title": "Outbound Balance Score",
|
||||||
|
"field": "balance_outbound_score",
|
||||||
|
"group": "commitment",
|
||||||
|
"y_min": 0,
|
||||||
|
"y_max": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "last_event",
|
||||||
|
"title": "Last Event Timestamp",
|
||||||
|
"field": "source_event_ts",
|
||||||
|
"group": "timeline",
|
||||||
|
"y_min": None,
|
||||||
|
"y_max": None,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -487,7 +654,10 @@ def _to_float(value):
|
|||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
def _format_metric_value(conversation, metric_slug):
|
def _format_metric_value(conversation, metric_slug, latest_snapshot=None):
|
||||||
|
snapshot = latest_snapshot
|
||||||
|
if snapshot is None:
|
||||||
|
snapshot = conversation.metric_snapshots.first()
|
||||||
if metric_slug == "platform":
|
if metric_slug == "platform":
|
||||||
return conversation.get_platform_type_display() or "-"
|
return conversation.get_platform_type_display() or "-"
|
||||||
if metric_slug == "thread":
|
if metric_slug == "thread":
|
||||||
@@ -498,6 +668,14 @@ def _format_metric_value(conversation, metric_slug):
|
|||||||
return conversation.get_stability_state_display()
|
return conversation.get_stability_state_display()
|
||||||
if metric_slug == "stability_score":
|
if metric_slug == "stability_score":
|
||||||
return conversation.stability_score
|
return conversation.stability_score
|
||||||
|
if metric_slug == "reciprocity_score":
|
||||||
|
return snapshot.reciprocity_score if snapshot else None
|
||||||
|
if metric_slug == "continuity_score":
|
||||||
|
return snapshot.continuity_score if snapshot else None
|
||||||
|
if metric_slug == "response_score":
|
||||||
|
return snapshot.response_score if snapshot else None
|
||||||
|
if metric_slug == "volatility_score":
|
||||||
|
return snapshot.volatility_score if snapshot else None
|
||||||
if metric_slug == "stability_confidence":
|
if metric_slug == "stability_confidence":
|
||||||
return conversation.stability_confidence
|
return conversation.stability_confidence
|
||||||
if metric_slug == "sample_messages":
|
if metric_slug == "sample_messages":
|
||||||
@@ -510,6 +688,14 @@ def _format_metric_value(conversation, metric_slug):
|
|||||||
return conversation.commitment_inbound_score
|
return conversation.commitment_inbound_score
|
||||||
if metric_slug == "commitment_outbound":
|
if metric_slug == "commitment_outbound":
|
||||||
return conversation.commitment_outbound_score
|
return conversation.commitment_outbound_score
|
||||||
|
if metric_slug == "inbound_response_score":
|
||||||
|
return snapshot.inbound_response_score if snapshot else None
|
||||||
|
if metric_slug == "outbound_response_score":
|
||||||
|
return snapshot.outbound_response_score if snapshot else None
|
||||||
|
if metric_slug == "balance_inbound_score":
|
||||||
|
return snapshot.balance_inbound_score if snapshot else None
|
||||||
|
if metric_slug == "balance_outbound_score":
|
||||||
|
return snapshot.balance_outbound_score if snapshot else None
|
||||||
if metric_slug == "commitment_confidence":
|
if metric_slug == "commitment_confidence":
|
||||||
return conversation.commitment_confidence
|
return conversation.commitment_confidence
|
||||||
if metric_slug == "commitment_computed":
|
if metric_slug == "commitment_computed":
|
||||||
@@ -2713,7 +2899,8 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||||
conversation = _conversation_for_person(request.user, person)
|
conversation = _conversation_for_person(request.user, person)
|
||||||
value = _format_metric_value(conversation, metric)
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
|
value = _format_metric_value(conversation, metric, latest_snapshot)
|
||||||
group = INSIGHT_GROUPS[spec["group"]]
|
group = INSIGHT_GROUPS[spec["group"]]
|
||||||
points = []
|
points = []
|
||||||
if spec["history_field"]:
|
if spec["history_field"]:
|
||||||
@@ -2773,6 +2960,7 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||||
conversation = _conversation_for_person(request.user, person)
|
conversation = _conversation_for_person(request.user, person)
|
||||||
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
metrics = []
|
metrics = []
|
||||||
for slug, spec in INSIGHT_METRICS.items():
|
for slug, spec in INSIGHT_METRICS.items():
|
||||||
metrics.append(
|
metrics.append(
|
||||||
@@ -2783,7 +2971,11 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
|||||||
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
|
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
|
||||||
"calculation": spec["calculation"],
|
"calculation": spec["calculation"],
|
||||||
"psychology": spec["psychology"],
|
"psychology": spec["psychology"],
|
||||||
"value": _format_metric_value(conversation, slug),
|
"value": _format_metric_value(
|
||||||
|
conversation,
|
||||||
|
slug,
|
||||||
|
latest_snapshot,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user