Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions hospexplorer/ask/admin.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
from django.contrib import admin
from ask.models import QARecord
from ask.models import Conversation, QARecord


class QARecordInline(admin.TabularInline):
model = QARecord
extra = 0
readonly_fields = ("question_text", "question_timestamp", "answer_text", "answer_timestamp", "is_error")
fields = ("question_text", "question_timestamp", "answer_text", "answer_timestamp", "is_error")


@admin.register(Conversation)
class ConversationAdmin(admin.ModelAdmin):
list_display = ("id", "title", "user", "created_at", "updated_at")
list_filter = ("user",)
search_fields = ("title", "user__username")
inlines = [QARecordInline]


@admin.register(QARecord)
class QARecordAdmin(admin.ModelAdmin):
list_display = ["id", "user", "truncated_question", "question_timestamp", "answer_timestamp"]
list_filter = ["question_timestamp", "user"]
list_display = ["id", "user", "conversation", "truncated_question", "question_timestamp", "answer_timestamp", "is_error"]
list_filter = ["question_timestamp", "user", "is_error"]
search_fields = ["question_text", "answer_text", "user__username"]
readonly_fields = ["question_timestamp", "answer_timestamp", "answer_raw_response"]
raw_id_fields = ["user"]
raw_id_fields = ["user", "conversation"]
date_hierarchy = "question_timestamp"

def truncated_question(self, obj):
Expand Down
26 changes: 26 additions & 0 deletions hospexplorer/ask/context_processors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.conf import settings
from django.urls import reverse
from ask.models import Conversation


def sidebar_conversations(request):
if request.user.is_authenticated:
limit = getattr(settings, "SIDEBAR_CONVERSATIONS_LIMIT", 10)
conversations = Conversation.objects.filter(
user=request.user
)[:limit]

sidebar_items = []
for conv in conversations:
label = conv.title if conv.title else conv.created_at.strftime("%b %d, %Y %I:%M %p")
sidebar_items.append({
"id": conv.id,
"label": label,
"url": reverse("ask:conversation", kwargs={"conversation_id": conv.id}),
"updated_at": conv.updated_at.isoformat(),
})
return {
"sidebar_conversations": sidebar_items,
"sidebar_conversations_limit": limit,
}
return {"sidebar_conversations": [], "sidebar_conversations_limit": 0}
16 changes: 15 additions & 1 deletion hospexplorer/ask/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-01-30 19:36
# Generated by Django 6.0.1 on 2026-02-20 21:44

import django.db.models.deletion
from django.conf import settings
Expand All @@ -14,6 +14,18 @@ class Migration(migrations.Migration):
]

operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversations', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='QARecord',
fields=[
Expand All @@ -23,6 +35,8 @@ class Migration(migrations.Migration):
('answer_text', models.TextField(blank=True, default='')),
('answer_raw_response', models.JSONField(default=dict)),
('answer_timestamp', models.DateTimeField(blank=True, null=True)),
('is_error', models.BooleanField(default=False)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='qa_records', to='ask.conversation')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='qa_records', to=settings.AUTH_USER_MODEL)),
],
options={
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 6.0.1 on 2026-02-04 23:32
# Generated by Django 6.0.1 on 2026-02-25 22:44

from django.db import migrations, models

Expand All @@ -11,8 +11,8 @@ class Migration(migrations.Migration):

operations = [
migrations.AddField(
model_name='qarecord',
name='is_error',
field=models.BooleanField(default=False),
model_name='conversation',
name='title',
field=models.CharField(blank=True, default='', max_length=200),
),
]
33 changes: 32 additions & 1 deletion hospexplorer/ask/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,37 @@
from django.conf import settings


class Conversation(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="conversations",
)
title = models.CharField(max_length=200, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
ordering = ["-updated_at"]

def __str__(self):
if self.title:
truncated = self.title[:50]
suffix = "..." if len(self.title) > 50 else ""
return f"Conversation {self.id}: {truncated}{suffix}"
return f"Conversation {self.id} ({self.user.username})"


class QARecord(models.Model):
"""
Stores a question-answer pair from user interactions with the LLM.
"""
conversation = models.ForeignKey(
Conversation,
on_delete=models.CASCADE,
related_name="qa_records",
)

# Question fields
question_text = models.TextField()
question_timestamp = models.DateTimeField(auto_now_add=True)
Expand All @@ -16,7 +43,11 @@ class QARecord(models.Model):
answer_timestamp = models.DateTimeField(null=True, blank=True)
is_error = models.BooleanField(default=False)

user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="qa_records")
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="qa_records",
)

class Meta:
ordering = ["-question_timestamp"]
Expand Down
1 change: 1 addition & 0 deletions hospexplorer/ask/static/css/ask.css
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,5 @@
.chat-input-bar {
padding: 0.5rem 1rem;
}

}
11 changes: 10 additions & 1 deletion hospexplorer/ask/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -10894,10 +10894,19 @@ body.sb-sidenav-toggled #wrapper #sidebar-wrapper {
}

#sidebar-wrapper .question-item:hover {
border-left-color: #0d6efd;
border-left-color: #8c1d40;
background-color: #f8f9fa;
}

#sidebar-wrapper .question-item.active {
border-left-color: #8c1d40;
background-color: rgba(140, 29, 64, 0.1);
color: #8c1d40;
font-weight: 600;
border-color: transparent;
border-left: 3px solid #8c1d40;
}

#sidebar-wrapper .sidebar-section-heading {
font-weight: 600;
text-transform: uppercase;
Expand Down
74 changes: 43 additions & 31 deletions hospexplorer/ask/templates/_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,41 @@
<div class="d-flex" id="wrapper">
<!-- Sidebar-->
<div class="border-end bg-white" id="sidebar-wrapper"
x-data="sidebarQuestions()"
@question-asked.window="addQuestion($event.detail)">
x-data="sidebarConversations()"
@conversation-updated.window="upsertConversation($event.detail)">
<div class="sidebar-heading border-bottom bg-white">
<img width="50" src="{% static 'assets/asu.png' %}">
Hopper
</div>
<div class="list-group list-group-flush">
<!-- Recent Questions Section -->
{% if user.is_authenticated %}
<div class="sidebar-section-heading small text-muted px-3 py-2 border-bottom bg-light">
Recent Questions
Conversations
</div>
<div class="p-3">
<form method="post" action="{% url 'ask:new-conversation' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary btn-sm w-100">New Chat +</button>
</form>
</div>
<template x-for="question in questions" :key="question.id">
<a href="#" class="list-group-item list-group-item-action py-2 px-3 question-item"
:title="question.question_text"
x-text="truncate(question.question_text, 35)">
{% endif %}
<!-- Conversations Section -->

<template x-for="conv in conversations" :key="conv.id">
<a :href="conv.url" class="list-group-item list-group-item-action py-2 px-3 question-item"
:class="{ 'active': conv.id === activeConversationId }"
:title="conv.label"
x-text="conv.label">
</a>
</template>
<div x-show="questions.length === 0" class="text-muted small px-3 py-2">
No questions yet
<div x-show="conversations.length === 0" class="text-muted small px-3 py-2">
No conversations yet
</div>

{% if user.is_authenticated %}
<div class="p-3 border-top mt-auto">
<div class="small text-muted mb-2">Signed in as <strong>{{ user.username }}</strong></div>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mb-2" data-bs-toggle="modal" data-bs-target="#deleteHistoryModal">Delete Question History</button>
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mb-2" data-bs-toggle="modal" data-bs-target="#deleteHistoryModal">Delete All Conversations</button>
<a href="{% url 'account_logout' %}" class="btn btn-outline-secondary btn-sm w-100">Sign Out</a>
</div>
{% endif %}
Expand Down Expand Up @@ -70,11 +80,11 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteHistoryModalLabel">Delete Question History</h5>
<h5 class="modal-title" id="deleteHistoryModalLabel">Delete All Conversations</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete your question history? This action is irreversible.
Are you sure you want to delete all your conversations? This action is irreversible.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No</button>
Expand Down Expand Up @@ -116,29 +126,31 @@ <h5 class="modal-title" id="deleteHistoryModalLabel">Delete Question History</h5
}
});
</script>
<!-- Sidebar Questions Component -->
<!-- Sidebar Conversations Component -->
{{ sidebar_conversations|json_script:"sidebar-conversations-data" }}
<script>
window.initialQuestions = {{ recent_questions_json|safe|default:"[]" }};
window.initialConversations = JSON.parse(
document.getElementById('sidebar-conversations-data').textContent
);

function sidebarQuestions() {
function sidebarConversations() {
return {
questions: window.initialQuestions || [],
conversations: window.initialConversations || [],
activeConversationId: {{ conversation.id|default:"null" }},
maxItems: {{ sidebar_conversations_limit|default:"10" }},

addQuestion(detail) {
const newQuestion = {
id: Date.now(),
question_text: detail.text
};
this.questions.unshift(newQuestion);
if (this.questions.length > 10) {
this.questions = this.questions.slice(0, 10);
upsertConversation(detail) {
this.conversations = this.conversations.filter(c => c.id !== detail.id);
this.conversations.unshift({
id: detail.id,
label: detail.label,
url: detail.url,
});
// 10 conversations shown in the sidebar
if (this.conversations.length > this.maxItems) {
this.conversations = this.conversations.slice(0, this.maxItems);
}
},

truncate(text, length) {
if (!text) return '';
if (text.length <= length) return text;
return text.substring(0, length) + '...';
this.activeConversationId = detail.id;
}
};
}
Expand Down
35 changes: 29 additions & 6 deletions hospexplorer/ask/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
<div
x-data="{
userQuery: '',
messages: [], // in-memory only; resets on page refresh (not persisted to localStorage, session, or DB)
messages: [
{% if conversation %}
{% for qa in conversation.qa_records.all reversed %}
{ role: 'user', content: '{{ qa.question_text|escapejs }}' },
{% if qa.answer_text %}
{ role: 'assistant', content: '{{ qa.answer_text|escapejs }}' },
{% endif %}
{% endfor %}
{% endif %}
],
conversationId: '{{ conversation.id|default_if_none:'' }}',
isLoading: false,
showScrollBtn: false,

Expand All @@ -21,12 +31,26 @@

this.isLoading = true;

// Dispatch event to update sidebar
$dispatch('question-asked', { text: question });

try {
const response = await fetch('{% url 'ask:query-llm' %}?query=' + encodeURIComponent(question));
let url = '{% url 'ask:query-llm' %}?query=' + encodeURIComponent(question);
if (this.conversationId) {
url += '&conversation_id=' + encodeURIComponent(this.conversationId);
}
const response = await fetch(url);
const data = await response.json();
// Update conversation ID and URL on first response
if (data.conversation_id && !this.conversationId) {
this.conversationId = data.conversation_id;
history.pushState({}, '', '{% url "ask:index" %}c/' + data.conversation_id + '/');
}
// Update sidebar with this conversation
if (data.conversation_id) {
$dispatch('conversation-updated', {
id: data.conversation_id,
label: data.conversation_title || question,
url: '{% url "ask:index" %}c/' + data.conversation_id + '/',
});
}
if (!response.ok || data.error) {
this.messages.push({ role: 'assistant', content: 'Something went wrong. Please try again.' });
} else {
Expand All @@ -35,7 +59,6 @@
} catch (error) {
this.messages.push({ role: 'assistant', content: 'Something went wrong. Please try again.' });
}
this.userQuery = '';
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
},
Expand Down
10 changes: 6 additions & 4 deletions hospexplorer/ask/urls.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from django.urls import re_path
from django.urls import path, re_path
from ask import views

app_name = "ask"

urlpatterns = [
re_path(r"^$", views.index, name="index"),
path("", views.index, name="index"),
path("new/", views.new_conversation, name="new-conversation"),
path("c/<int:conversation_id>/", views.conversation_detail, name="conversation"),
path("query/", views.query, name="query-llm"),
re_path(r"^mock$", views.mock_response, name="mock-response"),
re_path(r"^query$", views.query, name="query-llm"),
re_path(r"^history/delete$", views.delete_history, name="delete-history"),
]
]
Loading