diff --git a/README.md b/README.md
index caddf2d..02ae8e8 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
diff --git a/rdmo_chatbot/chatbot/adapter.py b/rdmo_chatbot/chatbot/adapter.py
index 227106c..8c7b66e 100644
--- a/rdmo_chatbot/chatbot/adapter.py
+++ b/rdmo_chatbot/chatbot/adapter.py
@@ -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
@@ -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")
+ 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)
+ await self.send_history(history, user)
else:
# if the history is empty, display the confirmation message
content = getattr(config, f"CONFIRMATION_{lang_code.upper()}", "")
@@ -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
@@ -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")
@@ -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
@@ -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):
diff --git a/rdmo_chatbot/plugin/static/chatbot/css/copilot.css b/rdmo_chatbot/plugin/static/chatbot/css/copilot.css
index 2a21e19..19286b7 100644
--- a/rdmo_chatbot/plugin/static/chatbot/css/copilot.css
+++ b/rdmo_chatbot/plugin/static/chatbot/css/copilot.css
@@ -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 {
diff --git a/rdmo_chatbot/plugin/static/chatbot/js/copilot.js b/rdmo_chatbot/plugin/static/chatbot/js/copilot.js
index d2b80ee..aef979c 100644
--- a/rdmo_chatbot/plugin/static/chatbot/js/copilot.js
+++ b/rdmo_chatbot/plugin/static/chatbot/js/copilot.js
@@ -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
}
@@ -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 () => {
+ 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')
@@ -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 })
-});
+});
\ No newline at end of file
diff --git a/rdmo_chatbot/plugin/templates/projects/project_interview.html b/rdmo_chatbot/plugin/templates/projects/project_interview.html
index 6f94d1c..826361f 100644
--- a/rdmo_chatbot/plugin/templates/projects/project_interview.html
+++ b/rdmo_chatbot/plugin/templates/projects/project_interview.html
@@ -37,9 +37,14 @@