Implement AI workspace and mitigation workflow

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

View File

@@ -234,7 +234,6 @@
<a class="navbar-item" href="{% url 'home' %}">
Home
</a>
{% if user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
@@ -284,9 +283,6 @@
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
Sessions
</a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queued Messages
</a>
</div>
</div>
@@ -311,6 +307,14 @@
</div>
<div class="navbar-end">
{% if user.is_authenticated %}
<a class="navbar-item" href="{% url 'ai_workspace' %}">
AI
</a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queue
</a>
{% endif %}
<div class="navbar-item">
<div class="buttons">
{% if not user.is_authenticated %}

View File

@@ -9,7 +9,7 @@
<script>
var grid = GridStack.init({
cellHeight: 20,
cellWidth: 50,
cellWidth: 45,
cellHeightUnit: 'px',
auto: true,
float: true,
@@ -78,9 +78,9 @@
// }
grid.compact();
});
</script>
<div>
{% block load_widgets %}
</script>
<div>
{% block load_widgets %}
<!-- <div
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="#"
@@ -88,7 +88,7 @@
hx-trigger="load"
hx-swap="afterend"
style="display: none;"></div> -->
{% endblock %}
</div>
{% endblock %}
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "index.html" %}
{% block load_widgets %}
<div
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'ai_workspace_contacts' type='widget' %}"
hx-target="#widgets-here"
hx-trigger="load"
hx-swap="afterend"
style="display: none;"></div>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "index.html" %}
{% block load_widgets %}
<div
<div
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'signal_accounts' type='widget' %}"
hx-target="#widgets-here"

View File

@@ -0,0 +1,213 @@
<div style="margin-bottom: 0.5rem;">
<div class="tags has-addons" style="display: inline-flex; margin-bottom: 0.4rem;">
<span class="tag is-dark">
<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
</span>
<span class="tag is-white" style="border: 1px solid rgba(0, 0, 0, 0.2);">
AI {{ operation_label }}
</span>
</div>
{% if error %}
<div class="notification is-danger is-light" style="padding: 0.6rem;">
{{ result_text }}
</div>
{% else %}
{% if operation == "artifacts" %}
{% if latest_plan %}
{% include "partials/ai-workspace-mitigation-panel.html" with person=person plan=latest_plan rules=latest_plan_rules games=latest_plan_games corrections=latest_plan_corrections fundamentals_text=latest_plan.fundamental_items|join:"\n" mitigation_messages=latest_plan_messages latest_export=latest_plan_export notice_message=mitigation_notice_message notice_level=mitigation_notice_level auto_settings=latest_auto_settings active_tab="plan_board" %}
{% else %}
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.2rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">No mitigation plan yet. Use the Patterns tab to generate one.</p>
</div>
{% endif %}
{% elif operation == "draft_reply" and draft_replies %}
<div id="draft-host-{{ person.id }}-{{ operation }}" data-selected="0">
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
{% for option in draft_replies %}
<div class="column is-12-mobile is-4-tablet" style="padding: 0.35rem;">
<article
class="draft-option-card {% if forloop.first %}is-selected{% endif %}"
data-index="{{ forloop.counter0 }}"
onclick="giaWorkspaceUseDraft('{{ person.id }}', '{{ operation }}', {{ forloop.counter0 }}); return false;"
style="height: 100%; padding: 0.6rem; border-radius: 9px; border: 1px solid rgba(0, 0, 0, 0.16); background: #fff; cursor: pointer; transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;">
<p class="is-size-7 has-text-weight-semibold is-flex is-align-items-center" style="margin-bottom: 0.35rem; gap: 0.35rem;">
{% with tone=option.label|default:""|lower %}
{% if tone == "soft" %}
<span class="icon is-small has-text-success"><i class="fa-solid fa-leaf"></i></span>
{% elif tone == "neutral" %}
<span class="icon is-small has-text-info"><i class="fa-solid fa-scale-balanced"></i></span>
{% elif tone == "firm" %}
<span class="icon is-small has-text-danger"><i class="fa-solid fa-shield-heart"></i></span>
{% else %}
<span class="icon is-small has-text-grey"><i class="fa-solid fa-comment-dots"></i></span>
{% endif %}
{% endwith %}
<span>{{ option.label|default:"Option" }}</span>
</p>
<p class="draft-text" style="white-space: pre-wrap; margin-bottom: 0;">{{ option.text }}</p>
</article>
</div>
{% endfor %}
</div>
</div>
<div id="draft-send-shell-{{ person.id }}-{{ operation }}" style="margin-top: 0.5rem; padding: 0.6rem; border: 1px solid rgba(0, 0, 0, 0.16); border-radius: 8px;">
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_send' type='widget' person_id=person.id %}"
hx-target="#draft-send-status-{{ person.id }}-{{ operation }}"
hx-swap="innerHTML">
<input type="hidden" id="draft-send-input-{{ person.id }}-{{ operation }}" name="draft_text" value="">
<input type="hidden" id="draft-send-force-{{ person.id }}-{{ operation }}" name="force_send" value="0">
<div class="field">
<label class="label is-small">Draft Preview</label>
<div class="control">
<textarea id="draft-send-preview-{{ person.id }}-{{ operation }}" class="textarea is-small" rows="4" readonly></textarea>
</div>
</div>
<div class="field" style="margin-bottom: 0.4rem;">
<div class="control buttons are-small" style="margin: 0; gap: 0.35rem;">
<button id="draft-send-btn-{{ person.id }}-{{ operation }}" class="button is-small is-link" {% if not send_state.can_send %}disabled{% endif %}>
Send Draft
</button>
<button
type="button"
class="button is-small is-info is-light"
onclick="giaWorkspaceQueueSelectedDraft('{{ person.id }}'); return false;">
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
<span>Add To Queue</span>
</button>
</div>
</div>
<div id="draft-send-status-{{ person.id }}-{{ operation }}"></div>
</form>
</div>
{% else %}
{% if operation == "extract_patterns" %}
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
{% for section in result_sections %}
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
<article class="box ai-section-box" style="height: 100%; padding: 0.65rem; margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
{% if section.level <= 2 %}
<h3 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h3>
{% elif section.level == 3 %}
<h4 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h4>
{% else %}
<h5 class="subtitle is-7 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ section.title }}</h5>
{% endif %}
{% for block in section.blocks %}
{% if block.type == "ul" %}
<ul style="margin: 0 0 0.45rem 1.15rem;">
{% for item in block.items %}
<li style="margin-bottom: 0.25rem;">{{ item }}</li>
{% endfor %}
</ul>
{% else %}
{% for item in block.items %}
<p style="margin-bottom: 0.45rem; white-space: pre-wrap;">{{ item }}</p>
{% endfor %}
{% endif %}
{% endfor %}
</article>
</div>
{% endfor %}
</div>
{% else %}
<div class="ai-section-stack">
{% for section in result_sections %}
<article class="box ai-section-box" style="padding: 0.65rem; margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
{% if section.level <= 2 %}
<h3 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h3>
{% elif section.level == 3 %}
<h4 class="title is-6" style="margin-bottom: 0.45rem;">{{ section.title }}</h4>
{% else %}
<h5 class="subtitle is-7 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ section.title }}</h5>
{% endif %}
{% for block in section.blocks %}
{% if block.type == "ul" %}
<ul style="margin: 0 0 0.45rem 1.15rem;">
{% for item in block.items %}
<li style="margin-bottom: 0.25rem;">{{ item }}</li>
{% endfor %}
</ul>
{% else %}
{% for item in block.items %}
<p style="margin-bottom: 0.45rem; white-space: pre-wrap;">{{ item }}</p>
{% endfor %}
{% endif %}
{% endfor %}
</article>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% if operation == "extract_patterns" %}
<article class="box" style="padding: 0.7rem; margin-top: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Create Framework / Rules / Games</p>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_create' type='widget' person_id=person.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML"
hx-indicator="#mitigation-create-loading-{{ person.id }}"
style="margin-bottom: 0;">
<input type="hidden" name="ai_result_id" value="{{ ai_result_id|default:'' }}">
<textarea name="source_text" style="display: none;">{{ result_text }}</textarea>
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Output</label>
<div class="select is-fullwidth is-small">
<select name="output_profile" required>
<option value="" selected disabled>Choose one</option>
<option value="framework">Framework (balanced)</option>
<option value="rule">Rule (minimal + strict)</option>
<option value="game">Game (engaging)</option>
</select>
</div>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Context (single freeform input)</label>
<textarea class="textarea is-small" rows="2" name="user_context" placeholder="Optional context or constraints"></textarea>
</div>
</div>
<button class="button is-small is-primary is-light">
<span class="icon is-small"><i class="fa-solid fa-chess-board"></i></span>
<span>Create Mitigation Plan</span>
</button>
<span id="mitigation-create-loading-{{ person.id }}" class="tag is-info is-light htmx-indicator" style="margin-left: 0.45rem;">
<span class="icon is-small"><i class="fa-solid fa-spinner fa-spin"></i></span>
<span>Building mitigation plan...</span>
</span>
</form>
</article>
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">
Plan editing is consolidated in the <strong>Plan</strong> tab.
</p>
</div>
{% endif %}
{% endif %}
</div>
<style>
.draft-option-card.is-selected {
border-color: rgba(54, 54, 54, 0.85) !important;
border-width: 2px !important;
box-shadow: inset 0 0 0 1px rgba(54, 54, 54, 0.18);
background-color: rgba(54, 54, 54, 0.06) !important;
}
.htmx-indicator {
display: none;
}
.htmx-request.htmx-indicator {
display: inline-flex;
}
</style>

View File

@@ -0,0 +1,660 @@
<div id="mitigation-shell-{{ person.id }}" style="margin-top: 0.7rem;">
<div class="is-flex is-justify-content-space-between is-align-items-start" style="gap: 0.5rem; margin-bottom: 0.5rem;">
<div>
<p class="is-size-7 has-text-weight-semibold">Pattern Mitigation</p>
<h4 class="title is-6" style="margin-bottom: 0.2rem;">{{ plan.title|default:"Mitigation Plan" }}</h4>
{% if plan.objective %}
<p class="is-size-7">{{ plan.objective }}</p>
{% endif %}
</div>
<span class="tag is-light">{{ plan.creation_mode|title }}</span>
</div>
{% if notice_message %}
<div class="notification is-{{ notice_level|default:'info' }} is-light" style="padding: 0.5rem 0.65rem; margin-bottom: 0.55rem;">
{{ notice_message }}
</div>
{% endif %}
<div class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0.55rem;">
<ul>
<li id="mitigation-tab-btn-{{ person.id }}-plan_board" class="is-active">
<a onclick="giaMitigationShowTab('{{ person.id }}', 'plan_board'); return false;">Rules &amp; Games</a>
</li>
<li id="mitigation-tab-btn-{{ person.id }}-corrections">
<a onclick="giaMitigationShowTab('{{ person.id }}', 'corrections'); return false;">Corrections</a>
</li>
<li id="mitigation-tab-btn-{{ person.id }}-engage">
<a onclick="giaMitigationShowTab('{{ person.id }}', 'engage'); return false;">Engage</a>
</li>
<li id="mitigation-tab-btn-{{ person.id }}-fundamentals">
<a onclick="giaMitigationShowTab('{{ person.id }}', 'fundamentals'); return false;">Fundamentals</a>
</li>
<li id="mitigation-tab-btn-{{ person.id }}-auto">
<a onclick="giaMitigationShowTab('{{ person.id }}', 'auto'); return false;">Auto</a>
</li>
<li id="mitigation-tab-btn-{{ person.id }}-ask_ai">
<a onclick="giaMitigationShowTab('{{ person.id }}', 'ask_ai'); return false;">Ask AI</a>
</li>
</ul>
</div>
<div id="mitigation-tab-{{ person.id }}-plan_board" class="mitigation-tab-pane">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.45rem; flex-wrap: wrap;">
<p class="is-size-7">Two lanes by type: rules on the left, games on the right.</p>
<div class="buttons are-small" style="margin: 0;">
<button
class="button is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_create' type='widget' person_id=person.id plan_id=plan.id kind='rule' %}"
hx-vals='{"active_tab":"plan_board"}'
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<span class="icon is-small"><i class="fa-solid fa-plus"></i></span>
<span>Rule</span>
</button>
<button
class="button is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_create' type='widget' person_id=person.id plan_id=plan.id kind='game' %}"
hx-vals='{"active_tab":"plan_board"}'
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<span class="icon is-small"><i class="fa-solid fa-plus"></i></span>
<span>Game</span>
</button>
</div>
</div>
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
<article class="box" style="min-height: 14rem; border: 1px solid rgba(0, 0, 0, 0.15); box-shadow: none;">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">RULES</p>
<button
type="button"
class="button is-small is-danger is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_delete_all' type='widget' person_id=person.id plan_id=plan.id kind='rule' %}"
hx-vals='{"active_tab":"plan_board"}'
hx-confirm="Delete all rules?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete All</button>
</div>
{% for rule in rules %}
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Rule</span>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<div class="field" style="margin-bottom: 0.35rem;">
<input class="input is-small" type="text" name="title" value="{{ rule.title }}" data-editable="1" readonly>
</div>
<div class="field" style="margin-bottom: 0.35rem;">
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ rule.content }}</textarea>
</div>
<input type="hidden" name="enabled" value="1">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
<div class="buttons are-small" style="margin: 0;">
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
<button
type="button"
class="button is-danger is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='rule' artifact_id=rule.id %}"
hx-vals='{"active_tab":"plan_board"}'
hx-confirm="Delete this rule?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete</button>
</div>
</form>
</article>
{% empty %}
<article class="box" style="padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
<p class="is-size-7 has-text-grey">No rules yet.</p>
</article>
{% endfor %}
</article>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.35rem;">
<article class="box" style="min-height: 14rem; border: 1px solid rgba(0, 0, 0, 0.15); box-shadow: none;">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; margin-bottom: 0.45rem;">
<p class="is-size-7 has-text-weight-bold" style="letter-spacing: 0.04em; margin: 0;">GAMES</p>
<button
type="button"
class="button is-small is-danger is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_delete_all' type='widget' person_id=person.id plan_id=plan.id kind='game' %}"
hx-vals='{"active_tab":"plan_board"}'
hx-confirm="Delete all games?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete All</button>
</div>
{% for game in games %}
<article class="box" style="padding: 0.55rem; margin-bottom: 0.45rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Game</span>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<div class="field" style="margin-bottom: 0.35rem;">
<input class="input is-small" type="text" name="title" value="{{ game.title }}" data-editable="1" readonly>
</div>
<div class="field" style="margin-bottom: 0.35rem;">
<textarea class="textarea is-small" rows="3" name="body" data-editable="1" readonly>{{ game.instructions }}</textarea>
</div>
<input type="hidden" name="enabled" value="1">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'plan_board' }}">
<div class="buttons are-small" style="margin: 0;">
<button type="button" class="button is-link is-light" data-edit-state="view" onclick="giaMitigationToggleEdit(this); return false;">Edit</button>
<button
type="button"
class="button is-danger is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='game' artifact_id=game.id %}"
hx-vals='{"active_tab":"plan_board"}'
hx-confirm="Delete this game?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete</button>
</div>
</form>
</article>
{% empty %}
<article class="box" style="padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
<p class="is-size-7 has-text-grey">No games yet.</p>
</article>
{% endfor %}
</article>
</div>
</div>
</div>
<div id="mitigation-tab-{{ person.id }}-corrections" class="mitigation-tab-pane" style="display: none;">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.45rem; flex-wrap: wrap;">
<p class="is-size-7">Corrections capture situation-specific clarification points.</p>
<div class="buttons are-small" style="margin: 0;">
<button
class="button is-small is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_create' type='widget' person_id=person.id plan_id=plan.id kind='correction' %}"
hx-vals='{"active_tab":"corrections"}'
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<span class="icon is-small"><i class="fa-solid fa-plus"></i></span>
<span>Correction</span>
</button>
<button
type="button"
class="button is-small is-danger is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_delete_all' type='widget' person_id=person.id plan_id=plan.id kind='correction' %}"
hx-vals='{"active_tab":"corrections"}'
hx-confirm="Delete all corrections?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete All</button>
</div>
</div>
{% if corrections %}
{% for correction in corrections %}
<article class="box" style="padding: 0.55rem; margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
<span class="tag is-light is-small" style="margin-bottom: 0.3rem;">Correction</span>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_save' type='widget' person_id=person.id plan_id=plan.id kind='correction' artifact_id=correction.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
<div class="column is-12" style="padding: 0.3rem;">
<input class="input is-small" type="text" name="title" value="{{ correction.title }}">
</div>
<div class="column is-12" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.2rem;">Message Context</label>
<textarea class="textarea is-small" rows="2" name="source_phrase">{{ correction.source_phrase }}</textarea>
</div>
<div class="column is-12" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.2rem;">Insight</label>
<textarea class="textarea is-small" rows="2" name="body">{{ correction.clarification }}</textarea>
</div>
</div>
<input type="hidden" name="enabled" value="1">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'corrections' }}">
<div class="buttons are-small" style="margin: 0;">
<button class="button is-small is-link is-light">Save Correction</button>
<button
type="button"
class="button is-small is-danger is-light"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_artifact_delete' type='widget' person_id=person.id plan_id=plan.id kind='correction' artifact_id=correction.id %}"
hx-vals='{"active_tab":"corrections"}'
hx-confirm="Delete this correction?"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">Delete</button>
</div>
</form>
</article>
{% endfor %}
{% else %}
<article class="box" style="padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
<p class="is-size-7 has-text-grey">No corrections yet.</p>
</article>
{% endif %}
</div>
<div id="mitigation-tab-{{ person.id }}-engage" class="mitigation-tab-pane" style="display: none;">
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none; margin-bottom: 0.55rem;">
<p class="is-size-7" style="margin-bottom: 0.45rem;">
Build a share-ready message from a rule, game, or correction. Voice framing now lives here.
</p>
<p class="is-size-7" style="margin-bottom: 0;"><strong>Send:</strong> {{ send_state.text }}</p>
</article>
<form
id="engage-form-{{ person.id }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_engage_share' type='widget' person_id=person.id plan_id=plan.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'engage' }}">
<input type="hidden" id="engage-action-input-{{ person.id }}" name="action" value="preview">
<input type="hidden" id="engage-force-send-{{ person.id }}" name="force_send" value="0">
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Source</label>
<div class="select is-small is-fullwidth">
<select name="source_ref" required onchange="giaEngageAutoPreview('{{ person.id }}');">
{% if engage_options %}
{% for option in engage_options %}
<option value="{{ option.value }}" {% if option.value == engage_form.source_ref %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
{% else %}
{% for rule in rules %}
<option value="rule:{{ rule.id }}">Rule: {{ rule.title }}</option>
{% endfor %}
{% for game in games %}
<option value="game:{{ game.id }}">Game: {{ game.title }}</option>
{% endfor %}
{% for correction in corrections %}
<option value="correction:{{ correction.id }}">Correction: {{ correction.title }}</option>
{% endfor %}
{% endif %}
</select>
</div>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Framing</label>
<input type="hidden" id="engage-framing-input-{{ person.id }}" name="framing" value="{{ engage_form.framing|default:'dont_change' }}">
<div id="engage-framing-tabs-{{ person.id }}" class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
<ul>
<li class="{% if engage_form.framing == 'dont_change' or engage_form.framing == 'neutral' or engage_form.framing == 'named' or not engage_form.framing %}is-active{% endif %}">
<a onclick="giaEngageSelect('{{ person.id }}', 'framing', 'dont_change', this); return false;">Don't Change</a>
</li>
<li class="{% if engage_form.framing == 'shared' %}is-active{% endif %}">
<a onclick="giaEngageSelect('{{ person.id }}', 'framing', 'shared', this); return false;">Shared (We/Us)</a>
</li>
</ul>
</div>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Share With</label>
<input type="hidden" id="engage-share-input-{{ person.id }}" name="share_target" value="{{ engage_form.share_target|default:'self' }}">
<div id="engage-share-tabs-{{ person.id }}" class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
<ul>
<li class="{% if engage_form.share_target == 'self' or not engage_form.share_target %}is-active{% endif %}">
<a onclick="giaEngageSelect('{{ person.id }}', 'share', 'self', this); return false;">Me</a>
</li>
<li class="{% if engage_form.share_target == 'other' %}is-active{% endif %}">
<a onclick="giaEngageSelect('{{ person.id }}', 'share', 'other', this); return false;">Other Party</a>
</li>
<li class="{% if engage_form.share_target == 'both' %}is-active{% endif %}">
<a onclick="giaEngageSelect('{{ person.id }}', 'share', 'both', this); return false;">Both Parties</a>
</li>
</ul>
</div>
</div>
<div class="column is-12" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Context (optional)</label>
<textarea class="textarea is-small" rows="2" name="context_note" placeholder="One additional note for this share.">{{ engage_form.context_note }}</textarea>
</div>
</div>
<div class="buttons are-small" style="margin-top: 0.15rem;">
<button id="engage-send-btn-{{ person.id }}" type="submit" class="button is-link is-light" onclick="giaEngageSetAction('{{ person.id }}', 'send');" {% if not send_state.can_send %}disabled{% endif %}>
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span>Send</span>
</button>
<button type="submit" class="button is-info is-light" onclick="giaEngageSetAction('{{ person.id }}', 'queue');">
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
<span>Add To Queue</span>
</button>
</div>
</form>
{% if engage_preview %}
<article class="box {% if engage_preview_flash %}engage-preview-flash{% endif %}" style="margin-top: 0.6rem; padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.35rem;">Preview</p>
<pre style="margin: 0; white-space: pre-wrap; font-size: 0.78rem; line-height: 1.36;">{{ engage_preview }}</pre>
</article>
{% else %}
<article class="box" style="margin-top: 0.6rem; padding: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.2); box-shadow: none;">
<p class="is-size-7 has-text-grey">No preview yet.</p>
</article>
{% endif %}
</div>
<div id="mitigation-tab-{{ person.id }}-fundamentals" class="mitigation-tab-pane" style="display: none;">
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
<div class="column is-12-mobile is-5-tablet" style="padding: 0.35rem;">
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none; height: 100%;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Current Fundamentals</p>
{% if plan.fundamental_items %}
<div class="content" style="margin-bottom: 0;">
<ul style="margin-top: 0;">
{% for item in plan.fundamental_items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="is-size-7 has-text-grey">No fundamentals yet.</p>
{% endif %}
</article>
</div>
<div class="column is-12-mobile is-7-tablet" style="padding: 0.35rem;">
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_fundamentals_save' type='widget' person_id=person.id plan_id=plan.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'fundamentals' }}">
<div class="field" style="margin-bottom: 0.45rem;">
<label class="label is-small">Edit Fundamentals (one per line)</label>
<textarea class="textarea is-small" rows="10" name="fundamentals_text">{{ fundamentals_text }}</textarea>
</div>
<button class="button is-small is-link is-light">Save Fundamentals</button>
</form>
</article>
</div>
</div>
</div>
<div id="mitigation-tab-{{ person.id }}-auto" class="mitigation-tab-pane" style="display: none;">
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none; margin-bottom: 0.55rem;">
<p class="is-size-7" style="margin-bottom: 0.35rem;">
Auto checks read recent message rows and can write linked mitigation objects for this workspace conversation.
</p>
<p class="is-size-7" style="margin-bottom: 0;">
Last run: {% if auto_settings.last_run_at %}{{ auto_settings.last_run_at }}{% else %}Never{% endif %}
</p>
{% if auto_settings.last_result_summary %}
<p class="is-size-7" style="margin-top: 0.35rem; margin-bottom: 0;">{{ auto_settings.last_result_summary }}</p>
{% endif %}
</article>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_auto' type='widget' person_id=person.id plan_id=plan.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<input type="hidden" name="active_tab" value="auto">
<div class="columns is-multiline" style="margin: 0 -0.3rem;">
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="checkbox is-size-7"><input type="checkbox" name="enabled" value="1" {% if auto_settings.enabled %}checked{% endif %}> Enable auto checks for this Conversation</label>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="checkbox is-size-7"><input type="checkbox" name="auto_pattern_recognition" value="1" {% if auto_settings.auto_pattern_recognition %}checked{% endif %}> Detect pattern signals from Message rows</label>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="checkbox is-size-7"><input type="checkbox" name="auto_create_mitigation" value="1" {% if auto_settings.auto_create_mitigation %}checked{% endif %}> Create a Plan when the Conversation has none</label>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="checkbox is-size-7"><input type="checkbox" name="auto_create_corrections" value="1" {% if auto_settings.auto_create_corrections %}checked{% endif %}> Create Correction rows linked to the Plan</label>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="checkbox is-size-7"><input type="checkbox" name="auto_notify_enabled" value="1" {% if auto_settings.auto_notify_enabled %}checked{% endif %}> Notify when auto writes new Correction rows</label>
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Message rows per check</label>
<input class="input is-small" type="number" min="10" max="200" name="sample_message_window" value="{{ auto_settings.sample_message_window }}">
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">Cooldown seconds between checks</label>
<input class="input is-small" type="number" min="0" max="86400" name="check_cooldown_seconds" value="{{ auto_settings.check_cooldown_seconds }}">
</div>
<div class="column is-12-mobile is-6-tablet" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">NTFY topic override for auto</label>
<input class="input is-small" type="text" name="ntfy_topic_override" value="{{ auto_settings.ntfy_topic_override|default:'' }}" placeholder="Optional topic override">
</div>
<div class="column is-12" style="padding: 0.3rem;">
<label class="label is-small" style="margin-bottom: 0.25rem;">NTFY URL override for auto</label>
<input class="input is-small" type="text" name="ntfy_url_override" value="{{ auto_settings.ntfy_url_override|default:'' }}" placeholder="Optional NTFY URL override">
</div>
<div class="column is-12" style="padding: 0.3rem;">
<p class="is-size-7 has-text-grey">If overrides are empty, notifications fall back to Notification Settings topic/url.</p>
</div>
</div>
<div class="buttons are-small" style="margin-top: 0.2rem;">
<button class="button is-link is-light" name="action" value="save">Save Auto Controls</button>
<button class="button is-primary is-light" name="action" value="run_now">Run Check Now</button>
</div>
</form>
</div>
<div id="mitigation-tab-{{ person.id }}-ask_ai" class="mitigation-tab-pane" style="display: none;">
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_export' type='widget' person_id=person.id plan_id=plan.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML"
style="margin-bottom: 0.55rem;">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'ask_ai' }}">
<div class="field is-grouped is-grouped-multiline is-align-items-flex-end" style="margin-bottom: 0; gap: 0.35rem;">
<div class="control">
<label class="label is-small" style="margin-bottom: 0.25rem;">Bundle</label>
<div class="select is-small">
<select name="artifact_type">
<option value="rulebook">Rulebook</option>
<option value="rules">Rules</option>
<option value="games">Games</option>
<option value="corrections">Corrections</option>
</select>
</div>
</div>
<div class="control">
<label class="label is-small" style="margin-bottom: 0.25rem;">Format</label>
<div class="select is-small">
<select name="export_format">
<option value="markdown">Markdown</option>
<option value="json">JSON</option>
<option value="text">Text</option>
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-link is-light" style="margin-top: 1.35rem;">
<span class="icon is-small"><i class="fa-solid fa-file-export"></i></span>
<span>Export</span>
</button>
</div>
</div>
</form>
{% if latest_export %}
<article class="box" style="padding: 0.55rem; margin-bottom: 0.6rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.3rem;">
Last Export: {{ latest_export.artifact_type|title }} ({{ latest_export.export_format|upper }})
</p>
<pre style="max-height: 14rem; overflow: auto; margin: 0; white-space: pre-wrap; font-size: 0.72rem; line-height: 1.28;">{{ latest_export.payload }}</pre>
</article>
{% endif %}
<article class="box" style="padding: 0.65rem; border: 1px solid rgba(0, 0, 0, 0.12); box-shadow: none;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.4rem;">Ask AI</p>
<div style="max-height: 12rem; overflow-y: auto; margin-bottom: 0.55rem; padding-right: 0.2rem;">
{% for message in mitigation_messages %}
<div style="margin-bottom: 0.45rem;">
<span class="tag is-light is-small">{{ message.role }}</span>
<div style="margin-top: 0.15rem; white-space: pre-wrap;">{{ message.text }}</div>
</div>
{% empty %}
<p class="is-size-7 has-text-grey">No messages yet.</p>
{% endfor %}
</div>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'ai_workspace_mitigation_chat' type='widget' person_id=person.id plan_id=plan.id %}"
hx-target="#mitigation-shell-{{ person.id }}"
hx-swap="outerHTML">
<input type="hidden" name="active_tab" value="{{ active_tab|default:'ask_ai' }}">
<div class="field" style="margin-bottom: 0.5rem;">
<div class="control">
<textarea name="message" class="textarea is-small" rows="2" placeholder="Refine the plan or request a new lens..."></textarea>
</div>
</div>
<button class="button is-small is-primary is-light">
<span class="icon is-small"><i class="fa-solid fa-comments"></i></span>
<span>Ask AI</span>
</button>
</form>
</article>
</div>
</div>
<style>
@keyframes engagePreviewPulse {
0% { background-color: rgba(255, 255, 255, 1); }
45% { background-color: rgba(236, 246, 255, 1); }
100% { background-color: rgba(255, 255, 255, 1); }
}
#mitigation-shell-{{ person.id }} .engage-preview-flash {
animation: engagePreviewPulse 850ms ease-in-out 1;
}
</style>
<script>
(function() {
const personId = "{{ person.id }}";
const canSend = "{{ send_state.can_send|yesno:'1,0' }}" === "1";
function resizeEditableTextareas(root) {
if (!root) return;
root.querySelectorAll('textarea[data-editable="1"]').forEach(function(area) {
area.style.height = "auto";
area.style.height = Math.max(area.scrollHeight, 72) + "px";
});
}
window.giaEngageSyncSendOverride = function(pid) {
if (pid !== personId) return;
const forceInput = document.getElementById("engage-force-send-" + pid);
const sendBtn = document.getElementById("engage-send-btn-" + pid);
const force =
!!(window.giaWorkspaceState
&& window.giaWorkspaceState[pid]
&& window.giaWorkspaceState[pid].forceSend);
if (forceInput) {
forceInput.value = force ? "1" : "0";
}
if (sendBtn) {
sendBtn.disabled = !canSend && !force;
}
};
function setActiveTabHiddenFields(tabName) {
const root = document.getElementById("mitigation-shell-" + personId);
if (!root) return;
root.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
input.value = tabName;
});
resizeEditableTextareas(root);
}
window.giaMitigationShowTab = function(pid, tabName) {
if (pid !== personId) return;
["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"].forEach(function(name) {
const pane = document.getElementById("mitigation-tab-" + personId + "-" + name);
const tab = document.getElementById("mitigation-tab-btn-" + personId + "-" + name);
if (!pane || !tab) return;
const active = name === tabName;
pane.style.display = active ? "block" : "none";
tab.classList.toggle("is-active", active);
});
setActiveTabHiddenFields(tabName);
};
window.giaMitigationToggleEdit = function(button) {
const form = button.closest("form");
if (!form) return;
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
button.dataset.editState = "edit";
button.textContent = "Save";
button.classList.remove("is-light");
resizeEditableTextareas(form);
} else {
form.requestSubmit();
}
};
window.giaEngageSetAction = function(pid, action) {
if (pid !== personId) return;
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) {
actionInput.value = action;
}
if (action === "send") {
window.giaEngageSyncSendOverride(pid);
}
};
window.giaEngageAutoPreview = function(pid) {
if (pid !== personId) return;
const form = document.getElementById("engage-form-" + pid);
if (!form) return;
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
};
window.giaEngageSelect = function(pid, kind, value, node) {
if (pid !== personId) return;
let inputId = "";
if (kind === "share") {
inputId = "engage-share-input-" + pid;
} else if (kind === "framing") {
inputId = "engage-framing-input-" + pid;
}
const input = inputId ? document.getElementById(inputId) : null;
if (input) {
input.value = value;
}
const li = node && node.closest ? node.closest("li") : null;
if (!li) return;
const ul = li.parentElement;
if (!ul) return;
Array.from(ul.children).forEach(function(child) {
child.classList.remove("is-active");
});
li.classList.add("is-active");
window.giaEngageAutoPreview(pid);
};
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");
resizeEditableTextareas(document.getElementById("mitigation-shell-" + personId));
window.giaEngageSyncSendOverride(personId);
})();
</script>

View File

@@ -0,0 +1,5 @@
<div id="mitigation-shell-{{ person.id }}" class="box" style="padding: 0.65rem; margin-top: 0.65rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<div class="notification is-{{ level|default:'info' }} is-light" style="padding: 0.55rem 0.7rem; margin: 0;">
{{ message }}
</div>
</div>

View File

@@ -0,0 +1,698 @@
<div
class="ai-person-widget"
id="ai-person-widget-{{ person.id }}"
data-run-url-template="{% url 'ai_workspace_run' type='widget' person_id=person.id operation='summarise' %}"
data-send-url="{% url 'ai_workspace_send' type='widget' person_id=person.id %}"
data-queue-url="{% url 'ai_workspace_queue' type='widget' person_id=person.id %}"
data-limit="{{ limit }}"
data-can-send="{{ send_state.can_send|yesno:'1,0' }}">
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
<p class="is-size-7">Showing last {{ limit }} messages.</p>
</div>
<div class="notification is-{{ send_state.level }} is-light" style="padding: 0.5rem 0.75rem;">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.4rem; flex-wrap: wrap;">
<div><strong>Send:</strong> {{ send_state.text }}</div>
<div class="buttons are-small" style="margin: 0;">
{% if not send_state.can_send %}
<button
type="button"
id="draft-override-top-btn-{{ person.id }}"
class="button is-warning is-light"
onclick="giaWorkspaceEnableSendOverride('{{ person.id }}', 'draft_reply'); return false;">
<span class="icon is-small"><i class="fa-solid fa-triangle-exclamation"></i></span>
<span>Allow Send In Pane</span>
</button>
{% endif %}
</div>
</div>
<div id="draft-top-status-{{ person.id }}" style="margin-top: 0.5rem;"></div>
</div>
<form id="ai-op-form-{{ person.id }}" style="margin-bottom: 0.75rem;">
<input type="hidden" name="limit" value="{{ limit }}">
<div class="field">
<label class="label is-small">Notes</label>
<div class="control">
<textarea class="textarea is-small" name="user_notes" rows="2" placeholder="Optional intent/context"></textarea>
</div>
</div>
</form>
<div id="ai-response-shell-{{ person.id }}" style="display: block; margin-bottom: 0.9rem;">
<div class="ai-response-capsule" style="margin-bottom: 0.5rem; border: 1px solid rgba(0, 0, 0, 0.16); border-radius: 8px; padding: 0.5rem 0.6rem;">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.4rem;">
<div class="tabs is-small is-toggle is-toggle-rounded" style="margin-bottom: 0;">
<ul>
<li id="ai-tab-{{ person.id }}-artifacts">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">Plan</a>
</li>
<li id="ai-tab-{{ person.id }}-summarise">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">Summary</a>
</li>
<li id="ai-tab-{{ person.id }}-draft_reply" class="is-active">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">Draft</a>
</li>
<li id="ai-tab-{{ person.id }}-extract_patterns">
<a onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">Patterns</a>
</li>
</ul>
</div>
<div class="is-flex is-align-items-center" style="gap: 0.35rem;">
<span id="ai-cache-indicator-{{ person.id }}" class="tag is-warning is-light is-small" style="display: none;">
Cached
</span>
<button
type="button"
class="button is-small is-ghost"
title="Refresh current tab"
onclick="giaWorkspaceRefresh('{{ person.id }}'); return false;">
<span class="icon is-small"><i class="fa-solid fa-rotate-right"></i></span>
</button>
</div>
</div>
<div id="ai-stage-{{ person.id }}" style="min-height: 7rem;">
<div id="ai-pane-{{ person.id }}-artifacts" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-warning is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'artifacts', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
<span>Plan</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-summarise" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-link is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'summarise', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
<span>Summary</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-draft_reply" class="ai-pane">
<button
type="button"
class="button is-primary is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'draft_reply', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Draft</span>
</button>
</div>
<div id="ai-pane-{{ person.id }}-extract_patterns" class="ai-pane" style="display: none;">
<button
type="button"
class="button is-info is-light is-small is-rounded"
onclick="giaWorkspaceRun('{{ person.id }}', 'extract_patterns', false); return false;">
<span class="icon is-small"><i class="fa-solid fa-wave-square"></i></span>
<span>Patterns</span>
</button>
</div>
</div>
</div>
</div>
<div id="ai-message-list-{{ person.id }}" style="max-height: 65vh; overflow-y: auto; padding-right: 0.25rem;">
{% if message_rows %}
{% for row in message_rows %}
<article class="media ai-message-row" data-ts="{{ row.message.ts }}" style="margin-bottom: 0.75rem;">
<div class="media-content">
<div
class="content"
style="margin-left: {% if row.direction == 'out' %}15%{% else %}0{% endif %}; margin-right: {% if row.direction == 'in' %}15%{% else %}0{% endif %};">
<div
style="margin-bottom: 0.25rem; padding: 0.6rem; border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.15); background: {% if row.direction == 'out' %}#f0f7ff{% else %}transparent{% endif %}; box-shadow: none;">
<p style="white-space: pre-wrap; margin-bottom: 0.35rem;">{{ row.message.text|default:"(no text)" }}</p>
<p class="is-size-7">
{{ row.ts_label }}
{% if row.message.custom_author %}
| {{ row.message.custom_author }}
{% endif %}
</p>
</div>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="has-text-grey">No messages found for this contact.</p>
{% endif %}
</div>
</div>
<style>
@keyframes aiFadeInUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-animate-in {
animation: aiFadeInUp 180ms ease-out;
}
.ai-response-capsule {
transition: all 180ms ease-out;
}
</style>
<script>
(function() {
const personId = "{{ person.id }}";
const canSend = (document.getElementById("ai-person-widget-" + personId)?.dataset.canSend || "0") === "1";
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
const widget = document.getElementById("ai-person-widget-" + personId);
if (!widget) {
return;
}
window.giaWorkspaceState = window.giaWorkspaceState || {};
window.giaWorkspaceCache = window.giaWorkspaceCache || (function() {
try {
// One-time migration flush to avoid stale cached pane HTML from earlier UI schema.
localStorage.removeItem("gia_workspace_cache_v1");
localStorage.removeItem("gia_workspace_cache_v2");
return JSON.parse(localStorage.getItem("gia_workspace_cache_v3") || "{}");
} catch (e) {
return {};
}
})();
function persistCache() {
try {
localStorage.setItem("gia_workspace_cache_v3", JSON.stringify(window.giaWorkspaceCache));
} catch (e) {
// Ignore storage write issues.
}
}
function runUrl(operation) {
const template = widget.dataset.runUrlTemplate || "";
if (template.indexOf("/summarise/") >= 0) {
return template.replace("/summarise/", "/" + operation + "/");
}
return template.replace("summarise", operation);
}
function formData() {
const form = document.getElementById("ai-op-form-" + personId);
const params = new URLSearchParams(new FormData(form));
return params;
}
function cacheKey(operation) {
return personId + "|" + operation + "|" + formData().toString();
}
function applyForceSendState(operation) {
const force = !!(window.giaWorkspaceState[personId] && window.giaWorkspaceState[personId].forceSend);
const forceInput = document.getElementById("draft-send-force-" + personId + "-" + operation);
const sendBtn = document.getElementById("draft-send-btn-" + personId + "-" + operation);
if (forceInput) {
forceInput.value = force ? "1" : "0";
}
if (sendBtn && !canSend) {
sendBtn.disabled = !force;
}
}
function formatUtcLabel(tsMs) {
const ts = Number(tsMs || 0);
if (!ts) {
return "";
}
const dt = new Date(ts);
function pad(value) {
return String(value).padStart(2, "0");
}
return (
dt.getUTCFullYear()
+ "-" + pad(dt.getUTCMonth() + 1)
+ "-" + pad(dt.getUTCDate())
+ " " + pad(dt.getUTCHours())
+ ":" + pad(dt.getUTCMinutes())
+ " UTC"
);
}
function appendOutgoingMessage(tsMs, text, author) {
const host = document.getElementById("ai-message-list-" + personId);
if (!host) {
return;
}
const noMessages = host.querySelector("p.has-text-grey");
if (noMessages) {
noMessages.remove();
}
const article = document.createElement("article");
article.className = "media ai-message-row";
article.dataset.ts = String(Number(tsMs || Date.now()));
article.style.marginBottom = "0.75rem";
const mediaContent = document.createElement("div");
mediaContent.className = "media-content";
const contentWrap = document.createElement("div");
contentWrap.className = "content";
contentWrap.style.marginLeft = "15%";
contentWrap.style.marginRight = "0";
const bubble = document.createElement("div");
bubble.style.marginBottom = "0.25rem";
bubble.style.padding = "0.6rem";
bubble.style.borderRadius = "6px";
bubble.style.border = "1px solid rgba(0, 0, 0, 0.15)";
bubble.style.background = "#f0f7ff";
bubble.style.boxShadow = "none";
const bodyP = document.createElement("p");
bodyP.style.whiteSpace = "pre-wrap";
bodyP.style.marginBottom = "0.35rem";
bodyP.textContent = text || "(no text)";
const metaP = document.createElement("p");
metaP.className = "is-size-7";
metaP.textContent = formatUtcLabel(tsMs);
if (author) {
metaP.textContent += " | " + author;
}
bubble.appendChild(bodyP);
bubble.appendChild(metaP);
contentWrap.appendChild(bubble);
mediaContent.appendChild(contentWrap);
article.appendChild(mediaContent);
host.appendChild(article);
const maxRows = Math.max(5, Math.min(parseInt(widget.dataset.limit || "20", 10) || 20, 200));
const rows = host.querySelectorAll(".ai-message-row");
if (rows.length > maxRows) {
const removeCount = rows.length - maxRows;
for (let i = 0; i < removeCount; i += 1) {
if (rows[i] && rows[i].parentNode) {
rows[i].parentNode.removeChild(rows[i]);
}
}
}
host.scrollTop = host.scrollHeight;
}
function getCacheEntry(operation) {
const key = cacheKey(operation);
const raw = window.giaWorkspaceCache[key];
if (!raw) {
return null;
}
function evict() {
delete window.giaWorkspaceCache[key];
persistCache();
}
if (typeof raw === "string") {
// Backward compatibility: old format has no timestamp; treat as expired.
evict();
return null;
}
if (raw && typeof raw === "object" && typeof raw.html === "string") {
const ts = typeof raw.ts === "number" ? raw.ts : null;
if (!ts) {
evict();
return null;
}
if ((Date.now() - ts) > CACHE_TTL_MS) {
evict();
return null;
}
return { html: raw.html, ts: ts };
}
evict();
return null;
}
function formatCacheAge(ts) {
if (!ts) {
return "Cached";
}
const deltaSec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (deltaSec < 5) return "Cached just now";
if (deltaSec < 60) return "Cached " + deltaSec + "s ago";
if (deltaSec < 3600) return "Cached " + Math.floor(deltaSec / 60) + "m ago";
if (deltaSec < 86400) return "Cached " + Math.floor(deltaSec / 3600) + "h ago";
return "Cached " + Math.floor(deltaSec / 86400) + "d ago";
}
function executeInlineScripts(container) {
if (!container) {
return;
}
const scripts = container.querySelectorAll("script");
scripts.forEach(function(oldScript) {
const newScript = document.createElement("script");
if (oldScript.src) {
newScript.src = oldScript.src;
} else {
newScript.textContent = oldScript.textContent || "";
}
Array.from(oldScript.attributes || []).forEach(function(attr) {
if (attr.name !== "src") {
newScript.setAttribute(attr.name, attr.value);
}
});
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
function setCachedIndicator(show, ts) {
const indicator = document.getElementById("ai-cache-indicator-" + personId);
if (!indicator) {
return;
}
if (show) {
indicator.textContent = formatCacheAge(ts);
}
indicator.style.display = show ? "inline-flex" : "none";
}
function hydrateCachedIfAvailable(operation) {
if (operation === "artifacts") {
return false;
}
const entry = getCacheEntry(operation);
const pane = document.getElementById("ai-pane-" + personId + "-" + operation);
if (!pane) {
return false;
}
if (entry && !pane.dataset.loaded) {
pane.innerHTML = entry.html;
pane.dataset.loaded = "1";
executeInlineScripts(pane);
if (window.htmx) {
window.htmx.process(pane);
}
return true;
}
return false;
}
window.giaWorkspaceShowTab = function(pid, operation) {
if (pid !== personId) {
return;
}
["artifacts", "summarise", "draft_reply", "extract_patterns"].forEach(function(op) {
const tab = document.getElementById("ai-tab-" + personId + "-" + op);
const pane = document.getElementById("ai-pane-" + personId + "-" + op);
if (!tab || !pane) {
return;
}
if (op === operation) {
tab.classList.add("is-active");
pane.style.display = "block";
} else {
tab.classList.remove("is-active");
pane.style.display = "none";
}
});
const hydrated = hydrateCachedIfAvailable(operation);
const entry = operation === "artifacts" ? null : getCacheEntry(operation);
setCachedIndicator(hydrated || !!entry, entry ? entry.ts : null);
window.giaWorkspaceState[personId] = window.giaWorkspaceState[personId] || {};
window.giaWorkspaceState[personId].current = operation;
};
window.giaWorkspaceRun = function(pid, operation, forceRefresh) {
if (pid !== personId) {
return;
}
const cacheAllowed = operation !== "artifacts";
const shell = document.getElementById("ai-response-shell-" + personId);
const pane = document.getElementById("ai-pane-" + personId + "-" + operation);
if (!shell || !pane) {
return;
}
const currentState = window.giaWorkspaceState[personId] || {};
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
window.giaWorkspaceShowTab(personId, operation);
return;
}
window.giaWorkspaceShowTab(personId, operation);
const key = cacheKey(operation);
const entry = getCacheEntry(operation);
if (cacheAllowed && !forceRefresh && entry) {
pane.innerHTML = entry.html;
pane.dataset.loaded = "1";
pane.classList.remove("ai-animate-in");
void pane.offsetWidth;
pane.classList.add("ai-animate-in");
setCachedIndicator(true, entry.ts);
if (window.htmx) {
window.htmx.process(pane);
}
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0);
}
return;
}
setCachedIndicator(false, null);
pane.innerHTML = '<div class="notification is-light ai-animate-in">Loading...</div>';
const url = runUrl(operation) + "?" + formData().toString();
fetch(url, { method: "GET" })
.then(function(resp) { return resp.text(); })
.then(function(html) {
pane.innerHTML = html;
pane.dataset.loaded = "1";
executeInlineScripts(pane);
pane.classList.remove("ai-animate-in");
void pane.offsetWidth;
pane.classList.add("ai-animate-in");
if (cacheAllowed) {
window.giaWorkspaceCache[key] = {
html: html,
ts: Date.now(),
};
persistCache();
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
} else {
setCachedIndicator(false, null);
}
if (window.htmx) {
window.htmx.process(pane);
}
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0);
}
})
.catch(function() {
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
});
};
window.giaWorkspaceRefresh = function(pid) {
if (pid !== personId) {
return;
}
const current = (window.giaWorkspaceState[personId] && window.giaWorkspaceState[personId].current) || "summarise";
window.giaWorkspaceRun(personId, current, true);
};
window.giaWorkspaceUseDraft = function(pid, operation, index) {
if (pid !== personId) {
return;
}
const host = document.getElementById("draft-host-" + personId + "-" + operation);
const optionCard = host ? host.querySelector('.draft-option-card[data-index="' + index + '"]') : null;
const option = optionCard ? optionCard.querySelector(".draft-text") : null;
if (!option) {
return;
}
const cards = host ? host.querySelectorAll(".draft-option-card") : [];
cards.forEach(function(el) { el.classList.remove("is-selected"); });
if (optionCard) {
optionCard.classList.add("is-selected");
}
host.dataset.selected = String(index);
const sendShell = document.getElementById("draft-send-shell-" + personId + "-" + operation);
const hiddenInput = document.getElementById("draft-send-input-" + personId + "-" + operation);
const preview = document.getElementById("draft-send-preview-" + personId + "-" + operation);
if (!sendShell || !hiddenInput || !preview) {
return;
}
hiddenInput.value = option.textContent.trim();
preview.value = option.textContent.trim();
applyForceSendState(operation);
sendShell.classList.remove("ai-animate-in");
void sendShell.offsetWidth;
sendShell.classList.add("ai-animate-in");
};
window.giaWorkspaceEnableSendOverride = function(pid, operation) {
if (pid !== personId) {
return;
}
window.giaWorkspaceState[personId] = window.giaWorkspaceState[personId] || {};
window.giaWorkspaceState[personId].forceSend = true;
applyForceSendState(operation);
if (typeof window.giaEngageSyncSendOverride === "function") {
window.giaEngageSyncSendOverride(personId);
}
const overrideBtn = document.getElementById("draft-override-top-btn-" + personId);
if (overrideBtn) {
overrideBtn.classList.remove("is-warning");
overrideBtn.classList.add("is-success");
const labelNode = overrideBtn.querySelector("span:last-child");
if (labelNode) {
labelNode.textContent = "Override Enabled";
}
}
const statusHost = document.getElementById("draft-top-status-" + personId);
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-success is-light" style="padding: 0.45rem 0.6rem;">Send override enabled for this pane.</div>';
}
};
window.giaWorkspaceQueueSelectedDraft = function(pid) {
if (pid !== personId) {
return;
}
const queueUrl = widget.dataset.queueUrl;
const preview = document.getElementById("draft-send-preview-" + personId + "-draft_reply");
const statusHost = document.getElementById("draft-top-status-" + personId);
const text = preview ? preview.value.trim() : "";
if (!text) {
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-warning is-light" style="padding: 0.45rem 0.6rem;">Select a draft first, then queue it.</div>';
}
return;
}
const payload = new URLSearchParams();
payload.append("draft_text", text);
fetch(queueUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-CSRFToken": "{{ csrf_token }}",
},
body: payload.toString(),
})
.then(function(resp) { return resp.text(); })
.then(function(html) {
if (statusHost) {
statusHost.innerHTML = html;
}
})
.catch(function() {
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
}
});
};
if (typeof window.giaMitigationShowTab !== "function") {
window.giaMitigationShowTab = function(pid, tabName) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "auto", "ask_ai"];
names.forEach(function(name) {
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
if (!pane || !tab) {
return;
}
const active = (name === tabName);
pane.style.display = active ? "block" : "none";
tab.classList.toggle("is-active", active);
});
const shell = document.getElementById("mitigation-shell-" + pid);
if (!shell) {
return;
}
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
input.value = tabName;
});
};
}
if (typeof window.giaMitigationToggleEdit !== "function") {
window.giaMitigationToggleEdit = function(button) {
const form = button ? button.closest("form") : null;
if (!form) {
return;
}
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
button.dataset.editState = "edit";
button.textContent = "Save";
button.classList.remove("is-light");
} else {
form.requestSubmit();
}
};
}
if (typeof window.giaEngageSetAction !== "function") {
window.giaEngageSetAction = function(pid, action) {
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) {
actionInput.value = action;
}
};
}
if (typeof window.giaEngageAutoPreview !== "function") {
window.giaEngageAutoPreview = function(pid) {
const form = document.getElementById("engage-form-" + pid);
if (!form) {
return;
}
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
};
}
if (typeof window.giaEngageSelect !== "function") {
window.giaEngageSelect = function(pid, kind, value, node) {
let inputId = "";
if (kind === "share") {
inputId = "engage-share-input-" + pid;
} else if (kind === "framing") {
inputId = "engage-framing-input-" + pid;
}
const input = inputId ? document.getElementById(inputId) : null;
if (input) {
input.value = value;
}
const li = node && node.closest ? node.closest("li") : null;
if (li && li.parentElement) {
Array.from(li.parentElement.children).forEach(function(child) {
child.classList.remove("is-active");
});
li.classList.add("is-active");
}
window.giaEngageAutoPreview(pid);
};
}
window.giaWorkspaceMessageListeners = window.giaWorkspaceMessageListeners || {};
const existingListener = window.giaWorkspaceMessageListeners[personId];
if (existingListener) {
document.body.removeEventListener("gia-message-sent", existingListener);
}
const messageSentListener = function(evt) {
const detail = (evt && evt.detail) ? evt.detail : {};
if (!detail || String(detail.person_id || "") !== personId) {
return;
}
appendOutgoingMessage(
Number(detail.ts || Date.now()),
String(detail.text || ""),
String(detail.author || "BOT")
);
};
document.body.addEventListener("gia-message-sent", messageSentListener);
window.giaWorkspaceMessageListeners[personId] = messageSentListener;
window.giaWorkspaceRun(personId, "artifacts", false);
})();
</script>

View File

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

View File

@@ -0,0 +1,54 @@
<div class="ai-workspace-widget">
<div class="columns is-mobile is-gapless">
<div class="column is-12-mobile is-12-tablet">
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<p class="is-size-7 has-text-weight-semibold">AI Workspace</p>
<h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3>
<p class="is-size-7">
Pick a person to open their message timeline in a fresh pane.
</p>
</div>
<form id="ai-window-form" style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<label class="label is-small" for="id_limit">Window</label>
<div class="select is-fullwidth is-small">
{{ window_form.limit }}
</div>
<p class="help">{{ window_form.limit.help_text }}</p>
</form>
<div>
{% if contact_rows %}
<div class="buttons are-small" style="display: grid; gap: 0.5rem;">
{% for row in contact_rows %}
<button
class="button is-fullwidth"
style="border-radius: 8px; border: 0; background: transparent; box-shadow: none; padding: 0;"
hx-get="{% url 'ai_workspace_person' type='widget' person_id=row.person.id %}"
hx-include="#ai-window-form"
hx-target="#widgets-here"
hx-swap="afterend">
<span class="tags has-addons" style="display: inline-flex; width: 100%; margin: 0; white-space: nowrap;">
<span class="tag is-dark" style="min-width: 2.5rem; justify-content: center;">
<i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
</span>
<span class="tag is-white" style="flex: 1; display: inline-flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-left: 0.7rem; padding-right: 0.7rem; border-top: 1px solid rgba(0, 0, 0, 0.2); border-bottom: 1px solid rgba(0, 0, 0, 0.2);">
<span style="display: inline-flex; align-items: baseline; gap: 0.35rem; min-width: 0;">
<strong>{{ row.person.name }}</strong>
</span>
{% if row.last_ts_label %}
<small style="padding-left: 0.5rem;">{{ row.last_ts_label }}</small>
{% endif %}
</span>
<span class="tag is-dark" style="min-width: 3.25rem; justify-content: center;">{{ row.message_count }}</span>
</span>
</button>
{% endfor %}
</div>
{% else %}
<p class="has-text-grey">No contacts available yet.</p>
{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -21,11 +21,11 @@
<tr>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.user }}</td>

View File

@@ -25,11 +25,11 @@
<tr>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.name }}</td>

View File

@@ -22,24 +22,24 @@
{% for item in object_list %}
<tr>
<td>
<a
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</a>
</td>
<td>{{ item.session }}</td>
<td>{{ item.ts }}</td>
<td>
<a
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.sender_uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</a>
</td>
<td>{{ item.text }}</td>
<td>{{ item.custom_author }}</td>

View File

@@ -22,11 +22,11 @@
<tr>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.name }}</td>

View File

@@ -25,11 +25,11 @@
<tr>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.alias }}</td>

View File

@@ -3,69 +3,93 @@
{% get_last_invalidation 'core.QueuedMessage' as last %}
{% include 'mixins/partials/notify.html' %}
{% cache 600 objects_queue request.user.id object_list type last %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
<div
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>id</th>
<th>session</th>
<th>manipulation</th>
<th>ts</th>
<th>text</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.session }}</td>
<td>{{ item.manipulation }}</td>
<td>{{ item.ts }}</td>
<td>{{ item.text.length }}</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'queue_update' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-pencil"></i>
</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'queue_delete' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete {{ item.id }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span>
</button>
</div>
</td>
</tr>
{% endfor %}
hx-get="{{ list_url }}"
hx-target="#{{ context_object_name }}-table"
hx-swap="outerHTML">
</table>
{% endcache %}
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.75rem; gap: 0.5rem; flex-wrap: wrap;">
<div>
<h3 class="title is-6" style="margin-bottom: 0.15rem;">Outgoing Queue</h3>
<p class="is-size-7">Review queued drafts and approve or reject each message.</p>
</div>
<span class="tag is-dark is-medium">{{ object_list|length }} pending</span>
</div>
{% if object_list %}
<div class="columns is-multiline" style="margin: 0 -0.35rem;">
{% for item in object_list %}
<div class="column is-12" style="padding: 0.35rem;" id="queue-card-{{ item.id }}">
<article class="box" style="padding: 0.75rem; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
<div class="is-flex is-justify-content-space-between is-align-items-start" style="gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.5rem;">
<div>
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">{{ item.session.identifier.person.name }}</p>
<div class="tags" style="margin-bottom: 0.2rem;">
<span class="tag is-light">{{ item.session.identifier.service|title }}</span>
<span class="tag is-light">{{ item.manipulation.name }}</span>
<span class="tag is-light">{{ item.ts }}</span>
</div>
</div>
<div class="buttons are-small" style="margin: 0;">
<button
class="button is-success is-light"
hx-get="{% url 'message_accept_api' message_id=item.id %}"
hx-swap="none"
_="on htmx:afterRequest if event.detail.successful remove #queue-card-{{ item.id }} then trigger {{ context_object_name_singular }}Event on body end">
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
<span>Approve</span>
</button>
<button
class="button is-danger is-light"
hx-get="{% url 'message_reject_api' message_id=item.id %}"
hx-swap="none"
_="on htmx:afterRequest if event.detail.successful remove #queue-card-{{ item.id }} then trigger {{ context_object_name_singular }}Event on body end">
<span class="icon is-small"><i class="fa-solid fa-xmark"></i></span>
<span>Reject</span>
</button>
</div>
</div>
<div style="padding: 0.6rem; border-radius: 8px; border: 1px solid rgba(0, 0, 0, 0.12); background: rgba(255, 255, 255, 0.45); margin-bottom: 0.5rem;">
<p style="white-space: pre-wrap; margin: 0;">{{ item.text|default:"(empty draft)" }}</p>
</div>
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
<small class="has-text-grey">Queue ID: {{ item.id }}</small>
<div class="buttons are-small" style="margin: 0;">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'queue_update' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button is-light">
<span class="icon is-small"><i class="fa-solid fa-pen"></i></span>
<span>Edit</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'queue_delete' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Delete queued message {{ item.id }}?"
class="button is-light">
<span class="icon is-small"><i class="fa-solid fa-trash"></i></span>
<span>Delete</span>
</button>
</div>
</div>
</article>
</div>
{% endfor %}
</div>
{% else %}
<article class="box" style="padding: 0.8rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">Queue is empty.</p>
</article>
{% endif %}
</div>
{% endcache %}

View File

@@ -19,13 +19,13 @@
{% for item in object_list %}
<tr>
<td>
<a
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</a>
</td>
<td>{{ item.identifier }}</td>
<td>{{ item.last_interaction }}</td>

View File

@@ -85,38 +85,38 @@
</table>
<form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'signal_account_add' type=type %}"
hx-target="#modals-here"
hx-swap="innerHTML">
{% csrf_token %}
<div class="field has-addons">
<div id="device" class="control is-expanded has-icons-left">
<input
hx-post="{% url 'signal_account_add' type=type %}"
hx-target="#widgets-here"
hx-swap="innerHTML"
name="device"
class="input"
type="text"
placeholder="Account name">
<span class="icon is-small is-left">
<i class="fa-solid fa-plus"></i>
</span>
</div>
<div class="control">
<div class="field">
<button
id="search"
class="button is-fullwidth"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url 'signal_account_add' type=type %}"
hx-target="#modals-here"
hx-swap="innerHTML">
{% csrf_token %}
<div class="field has-addons">
<div id="device" class="control is-expanded has-icons-left">
<input
hx-post="{% url 'signal_account_add' type=type %}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="innerHTML">
Add account
</button>
hx-swap="innerHTML"
name="device"
class="input"
type="text"
placeholder="Account name">
<span class="icon is-small is-left">
<i class="fa-solid fa-plus"></i>
</span>
</div>
<div class="control">
<div class="field">
<button
id="search"
class="button is-fullwidth"
hx-post="{% url 'signal_account_add' type=type %}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="innerHTML">
Add account
</button>
</div>
</div>
</div>
</div>
</form>
</form>
{% endcache %}

View File

@@ -19,14 +19,14 @@
<tr>
<td>{{ item.source_number }}</td>
<td>
<a
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.source_uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
</a>
</td>
<td>{{ item.account }}</td>
<td>{{ item.source_name }}</td>
<td>

View File

@@ -7,52 +7,52 @@
{% if object_list is not None %}
<table
class="table is-fullwidth is-hoverable">
<thead>
<th>name</th>
<th>number</th>
<th>uuid</th>
<th>verified</th>
<th>blocked</th>
</thead>
{% for item in object_list.contacts %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.number }}</td>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>
{% if item.identity.status == "TRUSTED_VERIFIED" %}
<span class="icon">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
</td>
<td>
{% if item.blocked %}
<span class="icon">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
class="table is-fullwidth is-hoverable">
<thead>
<th>name</th>
<th>number</th>
<th>uuid</th>
<th>verified</th>
<th>blocked</th>
</thead>
{% for item in object_list.contacts %}
<tr>
<td>{{ item.name }}</td>
<td>{{ item.number }}</td>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>
{% if item.identity.status == "TRUSTED_VERIFIED" %}
<span class="icon">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
</td>
<td>
{% if item.blocked %}
<span class="icon">
<i class="fa-solid fa-check"></i>
</span>
{% else %}
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</table>
{% endif %}
{% endcache %}