Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ SETTINGS_EXPORT += ['CHATBOT_URL']

MIDDLEWARE.append('rdmo_chatbot.plugin.middleware.ChatbotMiddleware')

# Address of the Chainlit server serving the copilot widget. Provide an
# absolute URL if the frontend and chatbot are on different hosts. A
# relative path (e.g., '/chatbot') also works when both are served from
# the same domain.
CHATBOT_URL = 'http://localhost:8080'
CHATBOT_AUTH_SECRET = '' # secret long random string

Expand Down Expand Up @@ -263,4 +267,4 @@ Acknowledgement

We would like to thank the Federal Government and the Heads of Government of the Länder, as well as the
Joint Science Conference (GWK), for their funding and support within the framework of the NFDI4ING consortium.
Funded by the German Research Foundation (DFG) - project number 442146713."
Funded by the German Research Foundation (DFG) - project number 442146713.
41 changes: 30 additions & 11 deletions rdmo_chatbot/chatbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@

class BaseAdapter:

async def call_copilot(self, name, args={}, default=None):
return_value = default
if cl.context.session.client_type == "copilot":
return_value = await cl.CopilotFunction(name=name, args=args).acall()
return return_value
async def call_copilot(self, name: str, fallback = None, **kwargs):
if cl.context.session.client_type != "copilot":
return fallback

try:
result = await cl.CopilotFunction(name=name, args=kwargs).acall()
except Exception:
return fallback

return fallback if result is None else result

async def on_chat_start(self):
pass
Expand Down Expand Up @@ -62,13 +67,15 @@ async def on_chat_start(self):
cl.user_session.set("project_id", project_id)

# get lang_code from the copilot and store it in the session
lang_code = await self.call_copilot("getLangCode", default="en")
Copy link
Member Author

@MyPyDavid MyPyDavid Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this returned a None object at the start of chat or something like that, so that lang_code was None

lang_code = await self.call_copilot("getLangCode", fallback="en")
cl.user_session.set("lang_code", lang_code)

# check if we have a history, yet
if store.has_history(user.identifier, project_id):
content = getattr(config, f"CONTINUATION_{lang_code.upper()}", "")
await cl.Message(content=content).send()
history = store.get_history(user.identifier, project_id)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added here the chat history to the messages below this continuation message. Added it as a feature here but would make sense right?

await self.send_history(history, user)
else:
# if the history is empty, display the confirmation message
content = getattr(config, f"CONFIRMATION_{lang_code.upper()}", "")
Expand All @@ -93,7 +100,8 @@ async def on_user_message(self, message):
user = cl.user_session.get("user")

# get the full project from the copilot
project = await self.call_copilot("getProject", default={})
project = await self.call_copilot("getProject")
project = project if isinstance(project, dict) else {}
project_id = project.get("id")

# get the history from the store
Expand Down Expand Up @@ -141,6 +149,19 @@ async def on_user_message(self, message):

return response_message

async def send_history(self, history, user):
assistant_name = getattr(config, "ASSISTANT_NAME", "Assistant")

for message in history:
if isinstance(message, HumanMessage):
author = user.display_name or "You"
elif isinstance(message, AIMessage):
author = assistant_name
else:
continue

await cl.Message(content=message.content, author=author).send()

async def on_system_message(self, message):
try:
action = message.metadata.get("action")
Expand All @@ -153,7 +174,7 @@ async def on_system_message(self, message):
store.reset_history(user.identifier, project_id)

async def on_transfer(self, action):
await self.call_copilot("handleTransfer", args=action.payload)
await self.call_copilot("handleTransfer", **action.payload)

async def on_contact(self, action):
# get user and project_id from the session
Expand All @@ -163,9 +184,7 @@ async def on_contact(self, action):
# get the history from the store
history = store.get_history(user.identifier, project_id)

await self.call_copilot("openContactModal", args={
"history": messages_to_dicts(history)
})
await self.call_copilot("openContactModal", history=messages_to_dicts(history))


class OpenAILangChainAdapter(LangChainAdapter):
Expand Down
12 changes: 12 additions & 0 deletions rdmo_chatbot/plugin/static/chatbot/css/copilot.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@
height: 2em;
}

.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

/* remove the watermark at the bottom of the copilot */

.watermark {
Expand Down
176 changes: 140 additions & 36 deletions rdmo_chatbot/plugin/static/chatbot/js/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ function truncate(string, maxLength = 32) {
return string.length > maxLength ? string.slice(0, maxLength) + '…' : string;
}

const getCookie = (name) => {
return document.cookie
.split(';')
.map((cookie) => cookie.trim())
.filter((cookie) => cookie.startsWith(`${name}=`))
.map((cookie) => decodeURIComponent(cookie.split('=')[1]))
.shift()
}

const getLangCode = async (args) => {
return language
}
Expand Down Expand Up @@ -142,22 +151,34 @@ const openContactModal = async (args) => {

const submitButton = contactModal.querySelector('#chatbot-contact-submit')

$(submitButton).click(async () => {
const payload = {
subject: subjectInput.value,
message: messageInput.value
}
$(submitButton).off('click').on('click', async () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the submit button for send email was hanging(didn't do anything) for me at some point.

submitButton.disabled = true

await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': Cookies.get('csrftoken')
try {
const payload = {
subject: subjectInput.value,
message: messageInput.value
}

const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
}
})

if (!response.ok) {
throw new Error(`Failed to send contact email (${response.status})`)
}
})

$(contactModal).modal('hide')
$(contactModal).modal('hide')
} catch (error) {
console.error(error)
} finally {
submitButton.disabled = false
}
})

$(contactModal).modal('show')
Expand All @@ -184,41 +205,124 @@ const copilotEventHandler = async (event) => {

window.copilotEventHandler = copilotEventHandler

document.addEventListener("DOMContentLoaded", () => {
const observer = new MutationObserver((mutations, obs) => {
const copilot = document.getElementById("chainlit-copilot")
const shadow = copilot.shadowRoot
const observedShadows = new WeakSet()

const ensureDialogAccessibility = ({
container,
titleText,
descriptionText,
titleId,
descriptionId
}) => {
if (!container) {
return
}

const resolvedTitleId = titleId || `${container.id || 'dialog'}-title`
const resolvedDescriptionId =
descriptionId || `${container.id || 'dialog'}-description`

if (!container.querySelector(`#${resolvedTitleId}`)) {
const dialogTitle = document.createElement('h2')
dialogTitle.id = resolvedTitleId
dialogTitle.setAttribute('data-radix-dialog-title', '')
dialogTitle.textContent = titleText
dialogTitle.classList.add('sr-only')

const modal = shadow.getElementById("new-chat-dialog")
const confirmButton = shadow.getElementById("confirm")
container.prepend(dialogTitle)
}

if (!container.getAttribute('aria-labelledby')) {
container.setAttribute('aria-labelledby', resolvedTitleId)
}

if (descriptionText) {
if (!container.querySelector(`#${resolvedDescriptionId}`)) {
const dialogDescription = document.createElement('p')
dialogDescription.id = resolvedDescriptionId
dialogDescription.textContent = descriptionText
dialogDescription.classList.add('sr-only')

container.prepend(dialogDescription)
}

if (!container.getAttribute('aria-describedby')) {
container.setAttribute('aria-describedby', resolvedDescriptionId)
}
}
}

if (modal && confirmButton && !confirmButton.dataset.hasHandler) {
const handler = async (event) => {
event.stopPropagation()
const patchNewChatDialog = (shadow) => {
const modal = shadow.getElementById("new-chat-dialog")
const confirmButton = shadow.getElementById("confirm")

if (!modal || !confirmButton) {
return
}

const content = modal.matches?.('[data-radix-dialog-content]')
? modal
: modal.querySelector?.('[data-radix-dialog-content]') || modal

ensureDialogAccessibility({
container: content,
titleText: gettext('Start a new chat'),
descriptionText: gettext(
'This will reset the current conversation and start a new chat.'
),
titleId: 'chainlit-new-chat-title',
descriptionId: 'chainlit-new-chat-description'
})

if (!confirmButton.dataset.hasHandler) {
confirmButton.dataset.hasHandler = "true"

confirmButton.addEventListener(
"click",
() => {
window.sendChainlitMessage({
type: "system_message",
output: "",
metadata: {
"action": "reset_history",
"project": parseInt(projectId)
action: "reset_history",
project: projectId
}
})
},
{ capture: true }
)
}
}

// remove this listener so we don’t fire again
confirmButton.removeEventListener("click", handler)
const applyCopilotPatches = () => {
const copilot = document.getElementById("chainlit-copilot")
if (!copilot) {
return
}

// trigger the original click (React handles it)
setTimeout(() => confirmButton.click(), 500)
const shadow = copilot.shadowRoot

// mark handler as attached to avoid duplicates
confirmButton.dataset.hasHandler = "true"
}
if (!shadow) {
return
}

// attach the listener
confirmButton.addEventListener("click", handler)
}
})
patchNewChatDialog(shadow)

if (!observedShadows.has(shadow)) {
const shadowObserver = new MutationObserver(() => {
patchNewChatDialog(shadow)
})

shadowObserver.observe(shadow, { childList: true, subtree: true })
observedShadows.add(shadow)
}
}

document.addEventListener("DOMContentLoaded", () => {
const observer = new MutationObserver(applyCopilotPatches)

// Run once in case the widget is already rendered before we start observing.
applyCopilotPatches()

observer.observe(document.body, { childList: true, subtree: true })
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,14 @@
<script src="{{ settings.CHATBOT_URL }}/copilot/index.js"></script>
<script src="{% static 'chatbot/js/copilot.js' %}"></script>
<script>
const configuredChatbotUrl = "{{ settings.CHATBOT_URL }}";
const chainlitServer = configuredChatbotUrl
? new URL(configuredChatbotUrl, window.location.origin).toString()
: window.location.origin;

window.mountChainlitWidget({
language: "{% chatbot_language %}",
chainlitServer: "{{ settings.CHATBOT_URL }}",
chainlitServer,
accessToken: "{% chatbot_token request.user %}",
customCssUrl: "{% static 'chatbot/css/copilot.css' %}"
})
Expand Down
4 changes: 2 additions & 2 deletions rdmo_chatbot/plugin/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone

from django import template
from django.conf import settings
Expand All @@ -15,7 +15,7 @@ def get_chatbot_token(user):
"identifier": user.username,
"display_name": get_full_name(user),
"metadata": {},
"exp": datetime.now(UTC) + timedelta(minutes=60 * 24),
"exp": datetime.now(timezone.utc) + timedelta(minutes=60 * 24),
}

return jwt.encode(token_data, settings.CHATBOT_AUTH_SECRET, algorithm="HS256")