clinical / chat_app.ts
darsoarafa's picture
Upload 5 files
a4bc1a7 verified
// BIG FAT WARNING: to avoid the complexity of npm, this typescript is compiled in the browser
// there's currently no static type checking
import { marked } from 'https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.0/lib/marked.esm.js'
const convElement = document.getElementById('conversation')
const promptInput = document.getElementById('prompt-input') as HTMLInputElement
const spinner = document.getElementById('spinner')
// stream the response and render messages as each chunk is received
// data is sent as newline-delimited JSON
async function onFetchResponse(response: Response): Promise<void> {
let text = ''
let decoder = new TextDecoder()
if (response.ok) {
const reader = response.body.getReader()
while (true) {
const {done, value} = await reader.read()
if (done) {
break
}
text += decoder.decode(value)
addMessages(text)
spinner.classList.remove('active')
}
addMessages(text)
promptInput.disabled = false
promptInput.focus()
} else {
const text = await response.text()
console.error(`Unexpected response: ${response.status}`, {response, text})
throw new Error(`Unexpected response: ${response.status}`)
}
}
// The format of messages, this matches pydantic-ai both for brevity and understanding
// in production, you might not want to keep this format all the way to the frontend
interface Message {
role: string
content: string
timestamp: string
}
// take raw response text and render messages into the `#conversation` element
// Message timestamp is assumed to be a unique identifier of a message, and is used to deduplicate
// hence you can send data about the same message multiple times, and it will be updated
// instead of creating a new message elements
function addMessages(responseText: string) {
const lines = responseText.split('\n')
const messages: Message[] = lines.filter(line => line.length > 1).map(j => JSON.parse(j))
for (const message of messages) {
// we use the timestamp as a crude element id
const {timestamp, role, content} = message
const id = `msg-${timestamp}`
let msgDiv = document.getElementById(id)
if (!msgDiv) {
msgDiv = document.createElement('div')
msgDiv.id = id
msgDiv.title = `${role} at ${timestamp}`
msgDiv.classList.add('border-top', 'pt-2', role)
convElement.appendChild(msgDiv)
}
msgDiv.innerHTML = marked.parse(content)
}
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
}
function onError(error: any) {
console.error(error)
//document.getElementById('error').classList.remove('d-none')
//document.getElementById('spinner').classList.remove('active')
}
async function onSubmit(e: SubmitEvent): Promise<void> {
e.preventDefault()
spinner.classList.add('active')
const body = new FormData(e.target as HTMLFormElement)
promptInput.value = ''
promptInput.disabled = true
const response = await fetch('/chat/', {method: 'POST', body})
await onFetchResponse(response)
}
// call onSubmit when the form is submitted (e.g. user clicks the send button or hits Enter)
document.querySelector('form').addEventListener('submit', (e) => onSubmit(e).catch(onError))
// load messages on page load
fetch('/chat/').then(onFetchResponse).catch(onError)