projects / ai-chatbot

AI ChatBot

Group coursework chatbot that talks as William Shakespeare, with a React frontend, an Express backend, and an AI provider that falls back from Gemini to a small local model.

fig. 0x08speech bubble

Overview

This was the group coursework for Level 5 Software Engineering at Bournemouth, from September to December 2025. The brief was a web chatbot that lets you talk to a historical figure. Our team chose William Shakespeare, so the bot answers in archaic, roughly iambic English and is meant to know nothing invented after 1616.

There were four of us. I did most of the backend: the Express server, the AI provider and both AI implementations, the TOML config, and the Docker setup. The others built most of the React interface, the quick-topic buttons, the accessibility work and some of the API routing.

The landing screen, with the opening line and quick-start topics
The landing screen, with the opening line and quick-start topics

The brief

The unit's coursework (70% of the grade) asked for:

  • a React frontend and an Express backend, in JavaScript or TypeScript
  • a JSON-based scripting engine to control the conversation
  • an accessible, responsive interface
  • a report, a six-minute demo video and a peer review

The later lectures covered LLM APIs, prompt engineering and RAG, and chatbot UX, which is where the AI side came from. We didn't build the JSON scripting engine. The chatbot was fully configurable instead, and the lecturers didn't mark us down too harshly for it.

How it works

The frontend keeps the conversation in React state. When you send a message it posts the new message and the history so far to /api/chat. The backend passes both to an AIProvider, which decides who answers.

Who answers a message

Every AI sits behind the same small interface (generateText(prompt, history, apiKey)), and the provider tries them in order:

backend/src/provider/ai/ai.provider.tsview source ↗
    async generateResponse(        message: string,        history: AIHistory[],    ): Promise<AIResponse | AIError> {        try {            const apiKey = Environment.GetApiKey;            if (apiKey === "YOUR_API_KEY_HERE") {                console.log("API key not configured, falling back to local AI");                const text = await this.localAI.generateText(message, history, "");                if (text instanceof AIError) return text;                return { text, provider: 'local' };            }            const response = await this.ai.generateText(                message,                history,                Environment.GetApiKey,            );            if (response instanceof AIError) {                console.log(                    "Remote AI returned error, falling back to local AI",                );                console.log("Error details:", response);                const text = await this.localAI.generateText(message, history, "");                if (text instanceof AIError) return text;                return { text, provider: 'local' };            }            return { text: response, provider: 'genimi' };

If the config still has the placeholder keyL8-13, the request goes straight to the local model. Otherwise Gemini gets itL15-19, and if Gemini returns an errorL21-29 the local model answers instead. Each reply is tagged with the providerL31 that wrote it, and the frontend shows that as "Celestial" (Gemini) or "Earthly" (local). The reason for all this was the demo: a missing key, no network, or a used-up quota still had to give us an answer on camera.

The local model is Qwen1.5 0.5B Chat, running in Node through transformers.js. It downloads on first start (about 300 MB) and loads in the background so the server can take requests straight away. It tries WebGPU first and falls back to WASM. Qwen expects its own chat format, so the prompt is built by hand:

backend/src/provider/ai/ais/local.ai.tsview source ↗
    private formatChatPrompt(prompt: string, history: AIHistory[]): string {        // Format as a text prompt for the model        let formattedPrompt = "<|im_start|>system\nYou are William Shakespeare, you only speak in iambic pentameter and must use archaic phrases and metaphors in the style of one of his plays. The user will ask you questions, and you will respond helpfully in the style provided, only using knowledge that Shakespeare would know. For example, you should have no knowledge of any technology past his death date. Moreover, only use alphanumric characters. Here is the user's first message:<|im_end|>\n";        // Add conversation history        if (Array.isArray(history) && history.length > 0) {            for (const msg of history) {                if (msg.role === "user") {                    formattedPrompt += `<|im_start|>user\n${msg.text}<|im_end|>\n`;                } else if (msg.role === "bot" || msg.role === "model") {                    formattedPrompt += `<|im_start|>assistant\n${msg.text}<|im_end|>\n`;                }            }        }        // Add current user message        formattedPrompt += `<|im_start|>user\n${prompt}<|im_end|>\n<|im_start|>assistant\n`;        return formattedPrompt;    }

The system turnL3 holds the Shakespeare persona, the history loopL7-13 replays the conversation, and the prompt ends with an open assistant turnL17 for the model to fill. The whole local fallback went in on one afternoon: I started with the LaMini model, switched to Qwen1.5 about two hours later, and settled on this prompt format an hour after that.

Config and deployment. In development the backend reads a config.toml and writes a default one if it's missing. In production it reads environment variables instead, so the Docker image never holds a key. A multi-stage Dockerfile builds the frontend and runs the backend and a static file server together, started from compose.yaml.

There was also a cache layer for a while: a Cache interface with an in-memory version, plus a Redis version that was only ever a commented-out sketch. It backed my first API design, where you sent a message, polled until the reply was ready, then fetched it from the cache. Once we switched to one request that waits for the reply and carries the whole history, nothing used it, so I removed it in November.

Outcome

We handed in a working Shakespeare bot that runs from one container, with or without a Gemini key.

Reading the code back now, I found some bugs I'd want to own:

  • Role names don't match. The frontend stores bot messages with the provider name as their role (local or genimi), but the Gemini mapper only turns bot into model, and Gemini only accepts user and model. I think this means any Gemini request with history in it gets rejected and quietly falls back to the local model. The local formatter has the same problem the other way: it only replaysL10 bot and model turns, so it drops the bot's own earlier replies.
  • Typo in the provider name. The backend calls Gemini genimi, and the frontend checks for gemini, so even a real Gemini reply shows as "Earthly".
  • Key in the URL. The Gemini key goes in the query string. A request header is the better place for it, because URLs end up in logs.

The fallback design was the bit I was happiest with. It is also why the bugs above were easy to miss: the bot always answered, so nothing looked broken.