Repository: gradio-app/gradio

Issue:
gr.Chatbot does not properly render Chain of thought model output.
### Describe the bug

When the chatbot component receives some values that contain a <thought> tag the component is unable to properly render the text.  Even when markdown rendering is set to false the thought will still not be displayed but a gap will  be left. 

I would like to see the chatbot be able to display the thought tags AND render proper html. Allow the programmer to decide if they want to include the thoughs in their response or not by converting them to something else. 

This is annying for Chain of thought models. 

### Have you searched existing issues?  🔎

- [x] I have searched and found no existing issues

### Reproduction

```python
import gradio as gr

history = [ {'role': 'user', 'metadata': {'title': None, 'id': None, 'parent_id': None, 'duration': None, 'status': None}, 'content': 'hi', 'options': None}, \
            {'role': 'assistant', 'content': '<think>\nAlright, someone just said "hi." I should respond in a friendly and approachable way.\n\nI want to make sure they feel welcome and know I\'m here to help with anything they\
              need.\n\nMaybe I\'ll ask how I can assist them today to keep the conversation going.\n</think>\n\nHello! How can I assist you today?'}]

with gr.Blocks() as demo:

    chatbot = gr.Chatbot(value = history, type= "messages", group_consecutive_messages=False,  label ="Markdown Enabled",elem_id="chatbot", render_markdown = True)
    chatbot_no_markdown = gr.Chatbot(value = history, type= "messages", group_consecutive_messages=False,  label ="No markdown",elem_id="chatbot",  render_markdown = False)




demo.launch()

```


### Screenshot

You can see improper rendering in both cases. 
![Image](https://github.com/user-attachments/assets/df1567d7-6c48-4ed0-906e-4f8b1736e766)

### Logs

```shell

```

### System Info

```shell
------------------------------
Operating System: Windows
gradio version: 5.12.0
gradio_client version: 1.5.4

------------------------------------------------
gradio dependencies in your environment:

aiofiles: 23.2.1
anyio: 4.6.2.post1
audioop-lts is not installed.
fastapi: 0.115.6
ffmpy: 0.5.0
gradio-client==1.5.4 is not installed.
httpx: 0.27.2
huggingface-hub: 0.27.0
jinja2: 3.1.5
markupsafe: 2.1.5
numpy: 1.26.4
orjson: 3.10.13
packaging: 24.2
pandas: 2.2.3
pillow: 10.3.0
pydantic: 2.9.2
pydub: 0.25.1
python-multipart: 0.0.20
pyyaml: 6.0.2
ruff: 0.8.4
safehttpx: 0.1.6
semantic-version: 2.10.0
starlette: 0.41.3
tomlkit: 0.12.0
typer: 0.15.1
typing-extensions: 4.12.2
urllib3: 2.3.0
uvicorn: 0.30.1
authlib; extra == 'oauth' is not installed.
itsdangerous; extra == 'oauth' is not installed.


gradio_client dependencies in your environment:

fsspec: 2024.12.0
httpx: 0.27.2
huggingface-hub: 0.27.0
packaging: 24.2
typing-extensions: 4.12.2
websockets: 11.0.3
```

### Severity

I can work around it

Hints from issue comments:
@Solomin0: here is a quick and dirty workaround for anyone that might run into this. This is based off of an ollama generation.
```
bot_response = ollama_client.chat(
            model= selected_model,
            messages=history,
            stream= True,
            keep_alive=-1)

 for chunk in bot_response:
            if not chunk["done"]:
                response_chunk = chunk["message"]["content"]
                if "</think>" in chunk["message"]["content"] or "<think>" in chunk["message"]["content"] :
                    response_chunk = "--Thinking--"
                history[-1]["content"]+= response_chunk
                yield history

            else:
                elapsed = chunk["total_duration"]
 ```
![Image](https://github.com/user-attachments/assets/125cf6d9-268f-485e-8035-ab1e1b4851c8)





Solution patch:
diff --git a/.changeset/salty-taxis-unite.md b/.changeset/salty-taxis-unite.md
new file mode 100644
index 000000000..ed5dc5b4d
--- /dev/null
+++ b/.changeset/salty-taxis-unite.md
@@ -0,0 +1,7 @@
+---
+"@gradio/chatbot": minor
+"@gradio/markdown-code": minor
+"gradio": minor
+---
+
+feat:Chatbot `allow_tags`
diff --git a/gradio/components/chatbot.py b/gradio/components/chatbot.py
index 2f209651e..34e7df98b 100644
--- a/gradio/components/chatbot.py
+++ b/gradio/components/chatbot.py
@@ -237,6 +237,7 @@ class Chatbot(Component):
         show_copy_all_button=False,
         allow_file_downloads=True,
         group_consecutive_messages: bool = True,
+        allow_tags: list[str] | None = None,
     ):
         """
         Parameters:
@@ -277,6 +278,7 @@ class Chatbot(Component):
             show_copy_all_button: If True, will show a copy all button that copies all chatbot messages to the clipboard.
             allow_file_downloads: If True, will show a download button for chatbot messages that contain media. Defaults to True.
             group_consecutive_messages: If True, will display consecutive messages from the same role in the same bubble. If False, will display each message in a separate bubble. Defaults to True.
+            allow_tags: If provided, these tags will be preserved in the output chatbot messages, even if `sanitize_html` is `True`. For example, if this list is ["thinking"], the tags `<thinking>` and `</thinking>` will not be removed.
         """
         if type is None:
             warnings.warn(
@@ -326,6 +328,7 @@ class Chatbot(Component):
         self.allow_file_downloads = allow_file_downloads
         self.feedback_options = feedback_options
         self.feedback_value = feedback_value
+        self.allow_tags = allow_tags
         super().__init__(
             label=label,
             every=every,
diff --git a/js/chatbot/Chatbot.test.ts b/js/chatbot/Chatbot.test.ts
index 8fe06abbb..4b711e706 100644
--- a/js/chatbot/Chatbot.test.ts
+++ b/js/chatbot/Chatbot.test.ts
@@ -233,4 +233,19 @@ describe("Chatbot", () => {
 			expect.stringContaining("assistant: bot message one")
 		);
 	});
+
+	test("renders messages with allowed HTML tags", async () => {
+		const { container } = await render(Chatbot, {
+			loading_status,
+			label: "chatbot",
+			value: [["user message", "<thinking>processing query...</thinking>"]],
+			allow_tags: ["thinking"]
+		});
+
+		const botMessage = container.querySelector(".bot > div");
+		assert.exists(botMessage);
+		assert.isTrue(
+			botMessage?.innerHTML.includes("<thinking>processing query...</thinking>")
+		);
+	});
 });
diff --git a/js/chatbot/Index.svelte b/js/chatbot/Index.svelte
index 3d113e6e2..b64686cf7 100644
--- a/js/chatbot/Index.svelte
+++ b/js/chatbot/Index.svelte
@@ -47,6 +47,7 @@
 	export let _retryable = false;
 	export let _undoable = false;
 	export let group_consecutive_messages = true;
+	export let allow_tags: string[] | null = null;
 	export let latex_delimiters: {
 		left: string;
 		right: string;
@@ -184,6 +185,7 @@
 			msg_format={type}
 			root={gradio.root}
 			{allow_file_downloads}
+			{allow_tags}
 		/>
 	</div>
 </Block>
diff --git a/js/chatbot/shared/ChatBot.svelte b/js/chatbot/shared/ChatBot.svelte
index 4a5a73b87..9333f1638 100644
--- a/js/chatbot/shared/ChatBot.svelte
+++ b/js/chatbot/shared/ChatBot.svelte
@@ -90,6 +90,7 @@
 	export let _undoable = false;
 	export let like_user_message = false;
 	export let root: string;
+	export let allow_tags: string[] | null = null;
 
 	let target: HTMLElement | null = null;
 	let edit_index: number | null = null;
@@ -319,6 +320,7 @@
 					{msg_format}
 					{feedback_options}
 					{current_feedback}
+					{allow_tags}
 					show_like={role === "user" ? likeable && like_user_message : likeable}
 					show_retry={_retryable && is_last_bot_message(messages, value)}
 					show_undo={_undoable && is_last_bot_message(messages, value)}
diff --git a/js/chatbot/shared/Message.svelte b/js/chatbot/shared/Message.svelte
index 6bc5d3af3..67e8fb163 100644
--- a/js/chatbot/shared/Message.svelte
+++ b/js/chatbot/shared/Message.svelte
@@ -50,6 +50,7 @@
 	export let edit_message: string;
 	export let display_consecutive_in_same_bubble: boolean;
 	export let current_feedback: string | null = null;
+	export let allow_tags: string[] | null = null;
 	let messageElements: HTMLDivElement[] = [];
 	let previous_edit_mode = false;
 	let last_message_width = 0;
@@ -209,6 +210,7 @@
 								<MessageContent
 									{message}
 									{sanitize_html}
+									{allow_tags}
 									{latex_delimiters}
 									{render_markdown}
 									{_components}
diff --git a/js/chatbot/shared/MessageContent.svelte b/js/chatbot/shared/MessageContent.svelte
index d4f457662..93a8bf3a1 100644
--- a/js/chatbot/shared/MessageContent.svelte
+++ b/js/chatbot/shared/MessageContent.svelte
@@ -26,6 +26,7 @@
 	export let allow_file_downloads: boolean;
 	export let display_consecutive_in_same_bubble: boolean;
 	export let thought_index: number;
+	export let allow_tags: string[] | null = null;
 
 	export let message: NormalisedMessage;
 </script>
@@ -40,6 +41,7 @@
 			{line_breaks}
 			on:load={scroll}
 			{root}
+			{allow_tags}
 		/>
 	</div>
 {:else if message.type === "component" && message.content.component in _components}
diff --git a/js/markdown-code/MarkdownCode.svelte b/js/markdown-code/MarkdownCode.svelte
index 03a7e4377..3132d57c7 100644
--- a/js/markdown-code/MarkdownCode.svelte
+++ b/js/markdown-code/MarkdownCode.svelte
@@ -18,6 +18,7 @@
 	export let line_breaks = true;
 	export let header_links = false;
 	export let root: string;
+	export let allow_tags: string[] | null = null;
 
 	let el: HTMLSpanElement;
 	let html: string;
@@ -32,9 +33,27 @@
 		return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 	}
 
+	function escapeTags(content: string, tagsToEscape: string[]): string {
+		const tagPattern = tagsToEscape.map((tag) => ({
+			open: new RegExp(`<(${tag})(\\s+[^>]*)?>`, "gi"),
+			close: new RegExp(`</(${tag})>`, "gi")
+		}));
+
+		let result = content;
+
+		tagPattern.forEach((pattern) => {
+			result = result.replace(
+				pattern.open,
+				(match, tag, attributes) => `&lt;${tag}${attributes || ""}&gt;`
+			);
+			result = result.replace(pattern.close, (match, tag) => `&lt;/${tag}&gt;`);
+		});
+
+		return result;
+	}
+
 	function process_message(value: string): string {
 		let parsedValue = value;
-
 		if (render_markdown) {
 			const latexBlocks: string[] = [];
 			latex_delimiters.forEach((delimiter, index) => {
@@ -58,10 +77,13 @@
 			);
 		}
 
+		if (allow_tags) {
+			parsedValue = escapeTags(parsedValue, allow_tags);
+		}
+
 		if (sanitize_html && sanitize) {
 			parsedValue = sanitize(parsedValue, root);
 		}
-
 		return parsedValue;
 	}
 


Test patch:
diff --git a/test/components/test_chatbot.py b/test/components/test_chatbot.py
index 03710fdb1..7a31306d7 100644
--- a/test/components/test_chatbot.py
+++ b/test/components/test_chatbot.py
@@ -69,6 +69,7 @@ class TestChatbot:
             "bubble_full_width": None,
             "line_breaks": True,
             "layout": None,
+            "allow_tags": None,
             "show_copy_all_button": False,
             "examples": None,
         }