diff --git a/.changeset/wise-moose-exist.md b/.changeset/wise-moose-exist.md new file mode 100644 index 0000000000000..e2182b90e8c06 --- /dev/null +++ b/.changeset/wise-moose-exist.md @@ -0,0 +1,7 @@ +--- +"@gradio/chatbot": minor +"@gradio/icons": minor +"gradio": minor +--- + +feat:Improve tool UI and support nested thoughts diff --git a/demo/chatbot_nested_thoughts/run.ipynb b/demo/chatbot_nested_thoughts/run.ipynb new file mode 100644 index 0000000000000..980c941d3585d --- /dev/null +++ b/demo/chatbot_nested_thoughts/run.ipynb @@ -0,0 +1 @@ +{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: chatbot_nested_thoughts"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from gradio import ChatMessage\n", "import time\n", "\n", "sleep_time = 0.1\n", "long_sleep_time = 1\n", "\n", "def generate_response(history):\n", " history.append(\n", " ChatMessage(\n", " role=\"user\", content=\"What is the weather in San Francisco right now?\"\n", " )\n", " )\n", " yield history\n", " time.sleep(sleep_time)\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"In order to find the current weather in San Francisco, I will need to use my weather tool.\",\n", " )\n", " )\n", " yield history\n", " time.sleep(sleep_time)\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"\",\n", " metadata={\"title\": \"Gathering Weather Websites\", \"id\": 1},\n", " )\n", " )\n", " yield history\n", " time.sleep(long_sleep_time)\n", " history[-1].content = \"Will check: weather.com and sunny.org\"\n", " yield history\n", " time.sleep(sleep_time)\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"Received weather from weather.com.\",\n", " metadata={\"title\": \"API Success \u2705\", \"parent_id\": 1, \"id\": 2},\n", " )\n", " )\n", " yield history\n", " time.sleep(sleep_time)\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"API Error when connecting to sunny.org.\",\n", " metadata={\"title\": \"API Error \ud83d\udca5 \", \"parent_id\": 1, \"id\": 3},\n", " )\n", " )\n", " yield history\n", " time.sleep(sleep_time)\n", "\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"I will try yet again\",\n", " metadata={\"title\": \"I will try again\", \"id\": 4, \"parent_id\": 3},\n", " )\n", " )\n", " yield history\n", "\n", " time.sleep(sleep_time)\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"Failed again\",\n", " metadata={\"title\": \"Failed again\", \"id\": 6, \"parent_id\": 4},\n", " )\n", " )\n", " yield history\n", "\n", "with gr.Blocks() as demo:\n", " chatbot = gr.Chatbot(type=\"messages\", height=500, show_copy_button=True)\n", " demo.load(generate_response, chatbot, chatbot)\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} \ No newline at end of file diff --git a/demo/chatbot_nested_thoughts/run.py b/demo/chatbot_nested_thoughts/run.py new file mode 100644 index 0000000000000..3b868a544d249 --- /dev/null +++ b/demo/chatbot_nested_thoughts/run.py @@ -0,0 +1,79 @@ +import gradio as gr +from gradio import ChatMessage +import time + +sleep_time = 0.1 +long_sleep_time = 1 + +def generate_response(history): + history.append( + ChatMessage( + role="user", content="What is the weather in San Francisco right now?" + ) + ) + yield history + time.sleep(sleep_time) + history.append( + ChatMessage( + role="assistant", + content="In order to find the current weather in San Francisco, I will need to use my weather tool.", + ) + ) + yield history + time.sleep(sleep_time) + history.append( + ChatMessage( + role="assistant", + content="", + metadata={"title": "Gathering Weather Websites", "id": 1}, + ) + ) + yield history + time.sleep(long_sleep_time) + history[-1].content = "Will check: weather.com and sunny.org" + yield history + time.sleep(sleep_time) + history.append( + ChatMessage( + role="assistant", + content="Received weather from weather.com.", + metadata={"title": "API Success ✅", "parent_id": 1, "id": 2}, + ) + ) + yield history + time.sleep(sleep_time) + history.append( + ChatMessage( + role="assistant", + content="API Error when connecting to sunny.org.", + metadata={"title": "API Error 💥 ", "parent_id": 1, "id": 3}, + ) + ) + yield history + time.sleep(sleep_time) + + history.append( + ChatMessage( + role="assistant", + content="I will try yet again", + metadata={"title": "I will try again", "id": 4, "parent_id": 3}, + ) + ) + yield history + + time.sleep(sleep_time) + history.append( + ChatMessage( + role="assistant", + content="Failed again", + metadata={"title": "Failed again", "id": 6, "parent_id": 4}, + ) + ) + yield history + +with gr.Blocks() as demo: + chatbot = gr.Chatbot(type="messages", height=500, show_copy_button=True) + demo.load(generate_response, chatbot, chatbot) + +if __name__ == "__main__": + demo.launch() diff --git a/demo/chatbot_thoughts/run.ipynb b/demo/chatbot_thoughts/run.ipynb new file mode 100644 index 0000000000000..2bbda50eee008 --- /dev/null +++ b/demo/chatbot_thoughts/run.ipynb @@ -0,0 +1 @@ +{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: chatbot_thoughts"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from gradio import ChatMessage\n", "import time\n", "\n", "def simulate_thinking_chat(message: str, history: list):\n", " \"\"\"Mimicking thinking process and response\"\"\"\n", " # Add initial empty thinking message to chat history\n", "\n", " history.append( # Adds new message to the chat history list\n", " ChatMessage( # Creates a new chat message\n", " role=\"assistant\", # Specifies this is from the assistant\n", " content=\"\", # Initially empty content\n", " metadata={\"title\": \"Thinking... \"} # Setting a thinking header here\n", " )\n", " )\n", " time.sleep(0.5)\n", " yield history # Returns current state of chat history\n", " \n", " # Define the thoughts that LLM will \"think\" through\n", " thoughts = [\n", " \"First, I need to understand the core aspects of the query...\",\n", " \"Now, considering the broader context and implications...\",\n", " \"Analyzing potential approaches to formulate a comprehensive answer...\",\n", " \"Finally, structuring the response for clarity and completeness...\"\n", " ]\n", " \n", " # Variable to store all thoughts as they accumulate\n", " accumulated_thoughts = \"\"\n", " \n", " # Loop through each thought\n", " for thought in thoughts:\n", " time.sleep(0.5) # Add a samll delay for realism\n", " \n", " # Add new thought to accumulated thoughts with markdown bullet point\n", " accumulated_thoughts += f\"- {thought}\\n\\n\" # \\n\\n creates line breaks\n", " \n", " # Update the thinking message with all thoughts so far\n", " history[-1] = ChatMessage( # Updates last message in history\n", " role=\"assistant\",\n", " content=accumulated_thoughts.strip(), # Remove extra whitespace\n", " metadata={\"title\": \"Thinking...\"} # Shows thinking header\n", " )\n", " yield history # Returns updated chat history\n", " \n", " # After thinking is complete, adding the final response\n", " history.append(\n", " ChatMessage(\n", " role=\"assistant\",\n", " content=\"Based on my thoughts and analysis above, my response is: This dummy repro shows how thoughts of a thinking LLM can be progressively shown before providing its final answer.\"\n", " )\n", " )\n", " yield history # Returns final state of chat history\n", "\n", "# Gradio blocks with gr.chatbot\n", "with gr.Blocks() as demo:\n", " gr.Markdown(\"# Thinking LLM Demo \ud83e\udd14\")\n", " chatbot = gr.Chatbot(type=\"messages\", render_markdown=True)\n", " msg = gr.Textbox(placeholder=\"Type your message...\")\n", " \n", " msg.submit(\n", " lambda m, h: (m, h + [ChatMessage(role=\"user\", content=m)]),\n", " [msg, chatbot],\n", " [msg, chatbot]\n", " ).then(\n", " simulate_thinking_chat,\n", " [msg, chatbot],\n", " chatbot\n", " )\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} \ No newline at end of file diff --git a/demo/chatbot_thoughts/run.py b/demo/chatbot_thoughts/run.py new file mode 100644 index 0000000000000..2cc5bf44d1770 --- /dev/null +++ b/demo/chatbot_thoughts/run.py @@ -0,0 +1,71 @@ +import gradio as gr +from gradio import ChatMessage +import time + +def simulate_thinking_chat(message: str, history: list): + """Mimicking thinking process and response""" + # Add initial empty thinking message to chat history + + history.append( # Adds new message to the chat history list + ChatMessage( # Creates a new chat message + role="assistant", # Specifies this is from the assistant + content="", # Initially empty content + metadata={"title": "Thinking... "} # Setting a thinking header here + ) + ) + time.sleep(0.5) + yield history # Returns current state of chat history + + # Define the thoughts that LLM will "think" through + thoughts = [ + "First, I need to understand the core aspects of the query...", + "Now, considering the broader context and implications...", + "Analyzing potential approaches to formulate a comprehensive answer...", + "Finally, structuring the response for clarity and completeness..." + ] + + # Variable to store all thoughts as they accumulate + accumulated_thoughts = "" + + # Loop through each thought + for thought in thoughts: + time.sleep(0.5) # Add a samll delay for realism + + # Add new thought to accumulated thoughts with markdown bullet point + accumulated_thoughts += f"- {thought}\n\n" # \n\n creates line breaks + + # Update the thinking message with all thoughts so far + history[-1] = ChatMessage( # Updates last message in history + role="assistant", + content=accumulated_thoughts.strip(), # Remove extra whitespace + metadata={"title": "Thinking..."} # Shows thinking header + ) + yield history # Returns updated chat history + + # After thinking is complete, adding the final response + history.append( + ChatMessage( + role="assistant", + content="Based on my thoughts and analysis above, my response is: This dummy repro shows how thoughts of a thinking LLM can be progressively shown before providing its final answer." + ) + ) + yield history # Returns final state of chat history + +# Gradio blocks with gr.chatbot +with gr.Blocks() as demo: + gr.Markdown("# Thinking LLM Demo 🤔") + chatbot = gr.Chatbot(type="messages", render_markdown=True) + msg = gr.Textbox(placeholder="Type your message...") + + msg.submit( + lambda m, h: (m, h + [ChatMessage(role="user", content=m)]), + [msg, chatbot], + [msg, chatbot] + ).then( + simulate_thinking_chat, + [msg, chatbot], + chatbot + ) + +if __name__ == "__main__": + demo.launch() \ No newline at end of file diff --git a/gradio/components/chatbot.py b/gradio/components/chatbot.py index 73d8810a35eec..430382b4702b9 100644 --- a/gradio/components/chatbot.py +++ b/gradio/components/chatbot.py @@ -35,6 +35,8 @@ class MetadataDict(TypedDict): title: Union[str, None] + id: NotRequired[int | str] + parent_id: NotRequired[int | str] class Option(TypedDict): @@ -57,6 +59,7 @@ class MessageDict(TypedDict): role: Literal["user", "assistant", "system"] metadata: NotRequired[MetadataDict] options: NotRequired[list[Option]] + duration: NotRequired[int] class FileMessage(GradioModel): @@ -82,6 +85,8 @@ class ChatbotDataTuples(GradioRootModel): class Metadata(GradioModel): title: Optional[str] = None + id: Optional[int | str] = None + parent_id: Optional[int | str] = None class Message(GradioModel): @@ -89,6 +94,7 @@ class Message(GradioModel): metadata: Metadata = Field(default_factory=Metadata) content: Union[str, FileMessage, ComponentMessage] options: Optional[list[Option]] = None + duration: Optional[int] = None class ExampleMessage(TypedDict): @@ -110,6 +116,7 @@ class ChatMessage: content: str | FileData | Component | FileDataDict | tuple | list metadata: MetadataDict | Metadata = field(default_factory=Metadata) options: Optional[list[Option]] = None + duration: Optional[int] = None class ChatbotDataMessages(GradioRootModel): @@ -538,6 +545,7 @@ def _postprocess_message_messages( content=message.content, # type: ignore metadata=message.metadata, # type: ignore options=message.options, + duration=message.duration, ) elif isinstance(message, Message): return message diff --git a/js/chatbot/Chatbot.stories.svelte b/js/chatbot/Chatbot.stories.svelte index da8bc2abf7e8c..f6e4c119679bc 100644 --- a/js/chatbot/Chatbot.stories.svelte +++ b/js/chatbot/Chatbot.stories.svelte @@ -431,12 +431,73 @@ This document is a showcase of various Markdown capabilities.`, value: [ { role: "user", - content: "What is the weather like today?" + content: "What is 27 * 14?", + duration: 0.1 }, { role: "assistant", - metadata: { title: "☀️ Using Weather Tool" }, - content: "Weather looks sunny today." + duration: 10, + content: "Let me break this down step by step.", + metadata: { + id: 1, + title: "Solving multiplication", + parent_id: 0 + } + }, + { + role: "assistant", + content: "First, let's multiply 27 by 10: 27 * 10 = 270", + metadata: { + id: 2, + title: "Step 1", + parent_id: 1 + } + }, + { + role: "assistant", + content: + "We can do this quickly because multiplying by 10 just adds a zero", + metadata: { + id: 6, + title: "Quick Tip", + parent_id: 2 + } + }, + { + role: "assistant", + content: "Then multiply 27 by 4: 27 * 4 = 108", + metadata: { + id: 3, + title: "Step 2", + parent_id: 1 + } + }, + { + role: "assistant", + content: + "Adding these together: 270 + 108 = 378. Therefore, 27 * 14 = 378" + }, + { + role: "assistant", + content: "Let me verify this result using a different method.", + metadata: { + id: 4, + title: "Verification", + parent_id: 0 + } + }, + { + role: "assistant", + content: "Using the standard algorithm: 27 * 14 = (20 + 7) * (10 + 4)", + metadata: { + id: 5, + title: "Expanding", + parent_id: 4 + } + }, + { + role: "assistant", + content: "The result is confirmed to be 378." } ] }} diff --git a/js/chatbot/shared/ButtonPanel.svelte b/js/chatbot/shared/ButtonPanel.svelte index a025b50d0e97d..f9d2d2f18b5ee 100644 --- a/js/chatbot/shared/ButtonPanel.svelte +++ b/js/chatbot/shared/ButtonPanel.svelte @@ -2,9 +2,11 @@ import LikeDislike from "./LikeDislike.svelte"; import Copy from "./Copy.svelte"; import type { FileData } from "@gradio/client"; - import type { NormalisedMessage, TextMessage } from "../types"; + import type { NormalisedMessage, TextMessage, ThoughtNode } from "../types"; import { Retry, Undo, Edit, Check, Clear } from "@gradio/icons"; import { IconButtonWrapper, IconButton } from "@gradio/atoms"; + import { all_text, is_all_text } from "./utils"; + export let likeable: boolean; export let feedback_options: string[]; export let show_retry: boolean; @@ -22,25 +24,7 @@ export let layout: "bubble" | "panel"; export let dispatch: any; - function is_all_text( - message: NormalisedMessage[] | NormalisedMessage - ): message is TextMessage[] | TextMessage { - return ( - (Array.isArray(message) && - message.every((m) => typeof m.content === "string")) || - (!Array.isArray(message) && typeof message.content === "string") - ); - } - - function all_text(message: TextMessage[] | TextMessage): string { - if (Array.isArray(message)) { - return message.map((m) => m.content).join("\n"); - } - return message.content; - } - $: message_text = is_all_text(message) ? all_text(message) : ""; - $: show_copy = show_copy_button && message && is_all_text(message); @@ -134,7 +118,6 @@ .panel { display: flex; align-self: flex-start; - padding: 0 var(--spacing-xl); z-index: var(--layer-1); } diff --git a/js/chatbot/shared/ChatBot.svelte b/js/chatbot/shared/ChatBot.svelte index 3ad7d18c86556..4017a376a9458 100644 --- a/js/chatbot/shared/ChatBot.svelte +++ b/js/chatbot/shared/ChatBot.svelte @@ -395,13 +395,6 @@ } } - .message-wrap { - display: flex; - flex-direction: column; - justify-content: space-between; - margin-bottom: var(--spacing-xxl); - } - .message-wrap :global(.prose.chatbot.md) { opacity: 0.8; overflow-wrap: break-word; diff --git a/js/chatbot/shared/Message.svelte b/js/chatbot/shared/Message.svelte index d4f2a9e5cf510..9520b16056872 100644 --- a/js/chatbot/shared/Message.svelte +++ b/js/chatbot/shared/Message.svelte @@ -1,6 +1,5 @@ - -
-
-
e.key === "Enter" && toggleExpanded()} - > - {title} - - ▼ - -
- {#if expanded} -
- -
- {/if} -
-
- - diff --git a/js/chatbot/shared/MessageContent.svelte b/js/chatbot/shared/MessageContent.svelte index bc7daa38d7820..d4f4576622efa 100644 --- a/js/chatbot/shared/MessageContent.svelte +++ b/js/chatbot/shared/MessageContent.svelte @@ -96,10 +96,6 @@ {/if} diff --git a/js/chatbot/shared/utils.ts b/js/chatbot/shared/utils.ts index 7d54a3cae5e19..d0ca141f2b563 100644 --- a/js/chatbot/shared/utils.ts +++ b/js/chatbot/shared/utils.ts @@ -8,7 +8,8 @@ import type { TextMessage, NormalisedMessage, Message, - MessageRole + MessageRole, + ThoughtNode } from "../types"; import type { LoadedComponent } from "../../core/src/types"; import { Gradio } from "@gradio/utils"; @@ -102,28 +103,56 @@ export function normalise_messages( root: string ): NormalisedMessage[] | null { if (messages === null) return messages; - return messages.map((message, i) => { - if (typeof message.content === "string") { - return { - role: message.role, - metadata: message.metadata, - content: redirect_src_url(message.content, root), - type: "text", - index: i, - options: message.options - }; - } else if ("file" in message.content) { - return { - content: convert_file_message_to_component_message(message.content), - metadata: message.metadata, - role: message.role, - type: "component", - index: i, - options: message.options - }; - } - return { type: "component", ...message } as ComponentMessage; - }); + + const thought_map = new Map(); + + return messages + .map((message, i) => { + let normalized: NormalisedMessage = + typeof message.content === "string" + ? { + role: message.role, + metadata: message.metadata, + content: redirect_src_url(message.content, root), + type: "text", + index: i, + options: message.options + } + : "file" in message.content + ? { + content: convert_file_message_to_component_message( + message.content + ), + metadata: message.metadata, + role: message.role, + type: "component", + index: i, + options: message.options + } + : ({ type: "component", ...message } as ComponentMessage); + + // handle thoughts + const { id, title, parent_id } = message.metadata || {}; + if (parent_id) { + const parent = thought_map.get(String(parent_id)); + if (parent) { + const thought = { ...normalized, children: [] } as ThoughtNode; + parent.children.push(thought); + if (id && title) { + thought_map.set(String(id), thought); + } + return null; + } + } + if (id && title) { + const thought = { ...normalized, children: [] } as ThoughtNode; + thought_map.set(String(id), thought); + return thought; + } + + return normalized; + }) + .filter((msg): msg is NormalisedMessage => msg !== null); } export function normalise_tuples( @@ -254,3 +283,49 @@ export function get_components_from_messages( }); return Array.from(components); } + +export function get_thought_content(msg: NormalisedMessage, depth = 0): string { + let content = ""; + const indent = " ".repeat(depth); + + if (msg.metadata?.title) { + content += `${indent}${depth > 0 ? "- " : ""}${msg.metadata.title}\n`; + } + if (typeof msg.content === "string") { + content += `${indent} ${msg.content}\n`; + } + const thought = msg as ThoughtNode; + if (thought.children?.length > 0) { + content += thought.children + .map((child) => get_thought_content(child, depth + 1)) + .join(""); + } + return content; +} + +export function all_text(message: TextMessage[] | TextMessage): string { + if (Array.isArray(message)) { + return message + .map((m) => { + if (m.metadata?.title) { + return get_thought_content(m); + } + return m.content; + }) + .join("\n"); + } + if (message.metadata?.title) { + return get_thought_content(message); + } + return message.content; +} + +export function is_all_text( + message: NormalisedMessage[] | NormalisedMessage +): message is TextMessage[] | TextMessage { + return ( + (Array.isArray(message) && + message.every((m) => typeof m.content === "string")) || + (!Array.isArray(message) && typeof message.content === "string") + ); +} diff --git a/js/chatbot/types.ts b/js/chatbot/types.ts index e47250fe2b8dc..65a22b87a9761 100644 --- a/js/chatbot/types.ts +++ b/js/chatbot/types.ts @@ -4,6 +4,8 @@ export type MessageRole = "system" | "user" | "assistant"; export interface Metadata { title: string | null; + id?: number | string | null; + parent_id?: number | string | null; } export interface ComponentData { @@ -24,6 +26,7 @@ export interface Message { content: string | FileData | ComponentData; index: number | [number, number]; options?: Option[]; + duration?: number; } export interface TextMessage extends Message { @@ -52,3 +55,5 @@ export type message_data = export type TupleFormat = [message_data, message_data][] | null; export type NormalisedMessage = TextMessage | ComponentMessage; + +export type ThoughtNode = NormalisedMessage & { children: ThoughtNode[] }; diff --git a/js/icons/src/DropdownCircularArrow.svelte b/js/icons/src/DropdownCircularArrow.svelte new file mode 100644 index 0000000000000..16defed9c5e0d --- /dev/null +++ b/js/icons/src/DropdownCircularArrow.svelte @@ -0,0 +1,21 @@ + + + + + + diff --git a/js/icons/src/index.ts b/js/icons/src/index.ts index 9cfcae47b2482..dc979b759f042 100644 --- a/js/icons/src/index.ts +++ b/js/icons/src/index.ts @@ -16,6 +16,7 @@ export { default as Copy } from "./Copy.svelte"; export { default as Crop } from "./Crop.svelte"; export { default as Download } from "./Download.svelte"; export { default as DropdownArrow } from "./DropdownArrow.svelte"; +export { default as DropdownCircularArrow } from "./DropdownCircularArrow.svelte"; export { default as Edit } from "./Edit.svelte"; export { default as Erase } from "./Erase.svelte"; export { default as Error } from "./Error.svelte";