Browse Source

enh: <think> tag support

Timothy Jaeryang Baek 3 months ago
parent
commit
c9dc7299c5

+ 39 - 3
backend/open_webui/utils/middleware.py

@@ -1072,6 +1072,12 @@ async def process_chat_response(
                         },
                     )
 
+                # We might want to disable this by default
+                detect_reasoning = True
+
+                reasoning_start_time = None
+                reasoning_content = ""
+
                 async for line in response.body_iterator:
                     line = line.decode("utf-8") if isinstance(line, bytes) else line
                     data = line
@@ -1098,7 +1104,6 @@ async def process_chat_response(
                                     "selectedModelId": data["selected_model_id"],
                                 },
                             )
-
                         else:
                             value = (
                                 data.get("choices", [])[0]
@@ -1109,6 +1114,39 @@ async def process_chat_response(
                             if value:
                                 content = f"{content}{value}"
 
+                                if detect_reasoning:
+                                    if "<think>\n" in content:
+                                        reasoning_start_time = time.time()
+                                        reasoning_content = ""
+                                        content = content.replace("<think>\n", "")
+
+                                    if reasoning_start_time is not None:
+                                        reasoning_content += value
+
+                                        if "</think>\n" in reasoning_content:
+                                            reasoning_end_time = time.time()
+                                            reasoning_duration = int(
+                                                reasoning_end_time
+                                                - reasoning_start_time
+                                            )
+
+                                            reasoning_content = (
+                                                reasoning_content.strip("<think>\n")
+                                                .strip("</think>\n")
+                                                .strip()
+                                            )
+
+                                            if reasoning_content:
+                                                # Format reasoning with <details> tag
+                                                content = f"<details>\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_content}\n</details>\n"
+                                            else:
+                                                content = ""
+
+                                            reasoning_start_time = None
+                                        else:
+                                            # Show ongoing thought process
+                                            content = f"<details>\n<summary>Thinking… <loading/></summary>\n{reasoning_content}\n</details>\n"
+
                                 if ENABLE_REALTIME_CHAT_SAVE:
                                     # Save message in the database
                                     Chats.upsert_message_to_chat_by_id_and_message_id(
@@ -1129,10 +1167,8 @@ async def process_chat_response(
                                 "data": data,
                             }
                         )
-
                     except Exception as e:
                         done = "data: [DONE]" in line
-
                         if done:
                             pass
                         else:

+ 44 - 0
src/app.css

@@ -127,6 +127,50 @@ select {
 	-webkit-appearance: none;
 }
 
+@keyframes shimmer {
+	0% {
+		background-position: 200% 0;
+	}
+	100% {
+		background-position: -200% 0;
+	}
+}
+
+.shimmer {
+	background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
+	background-size: 200% 100%;
+	background-clip: text;
+	-webkit-background-clip: text;
+	-webkit-text-fill-color: transparent;
+	animation: shimmer 4s linear infinite;
+	color: #818286; /* Fallback color */
+}
+
+:global(.dark) .shimmer {
+	background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
+	background-size: 200% 100%;
+	background-clip: text;
+	-webkit-background-clip: text;
+	-webkit-text-fill-color: transparent;
+	animation: shimmer 4s linear infinite;
+	color: #a1a3a7; /* Darker fallback color for dark mode */
+}
+
+@keyframes smoothFadeIn {
+	0% {
+		opacity: 0;
+		transform: translateY(-10px);
+	}
+	100% {
+		opacity: 1;
+		transform: translateY(0);
+	}
+}
+
+.status-description {
+	animation: smoothFadeIn 0.2s forwards;
+}
+
 .katex-mathml {
 	display: none;
 }

+ 5 - 1
src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte

@@ -195,7 +195,11 @@
 			</ul>
 		{/if}
 	{:else if token.type === 'details'}
-		<Collapsible title={token.summary} className="w-fit space-y-1">
+		<Collapsible
+			title={token.summary}
+			isLoading={token?.isLoading ?? false}
+			className="w-fit space-y-1"
+		>
 			<div class=" mb-1.5" slot="content">
 				<svelte:self id={`${id}-${tokenIdx}-d`} tokens={marked.lexer(token.text)} />
 			</div>

+ 1 - 1
src/lib/components/chat/Messages/Name.svelte

@@ -1,3 +1,3 @@
-<div class=" self-center font-semibold mb-0.5 line-clamp-1 flex gap-1 items-center">
+<div class=" self-center font-semibold line-clamp-1 flex gap-1 items-center">
 	<slot />
 </div>

+ 0 - 44
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -1234,48 +1234,4 @@
 		-ms-overflow-style: none; /* IE and Edge */
 		scrollbar-width: none; /* Firefox */
 	}
-
-	@keyframes shimmer {
-		0% {
-			background-position: 200% 0;
-		}
-		100% {
-			background-position: -200% 0;
-		}
-	}
-
-	.shimmer {
-		background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
-		background-size: 200% 100%;
-		background-clip: text;
-		-webkit-background-clip: text;
-		-webkit-text-fill-color: transparent;
-		animation: shimmer 4s linear infinite;
-		color: #818286; /* Fallback color */
-	}
-
-	:global(.dark) .shimmer {
-		background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
-		background-size: 200% 100%;
-		background-clip: text;
-		-webkit-background-clip: text;
-		-webkit-text-fill-color: transparent;
-		animation: shimmer 4s linear infinite;
-		color: #a1a3a7; /* Darker fallback color for dark mode */
-	}
-
-	@keyframes smoothFadeIn {
-		0% {
-			opacity: 0;
-			transform: translateY(-10px);
-		}
-		100% {
-			opacity: 1;
-			transform: translateY(0);
-		}
-	}
-
-	.status-description {
-		animation: smoothFadeIn 0.2s forwards;
-	}
 </style>

+ 15 - 2
src/lib/components/common/Collapsible.svelte

@@ -9,12 +9,14 @@
 
 	import ChevronUp from '../icons/ChevronUp.svelte';
 	import ChevronDown from '../icons/ChevronDown.svelte';
+	import Spinner from './Spinner.svelte';
 
 	export let open = false;
 	export let className = '';
 	export let buttonClassName =
 		'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
 	export let title = null;
+	export let isLoading = false;
 
 	export let grow = false;
 
@@ -34,12 +36,23 @@
 				}
 			}}
 		>
-			<div class=" w-full font-medium flex items-center justify-between gap-2">
+			<div
+				class=" w-full font-medium flex items-center justify-between gap-2 {isLoading === true
+					? 'shimmer'
+					: ''}
+			"
+			>
+				{#if isLoading}
+					<div>
+						<Spinner className="size-4" />
+					</div>
+				{/if}
+
 				<div class="">
 					{title}
 				</div>
 
-				<div>
+				<div class="flex self-center translate-y-[1px]">
 					{#if open}
 						<ChevronUp strokeWidth="3.5" className="size-3.5" />
 					{:else}

+ 12 - 3
src/lib/utils/marked/extension.ts

@@ -18,18 +18,26 @@ function findMatchingClosingTag(src, openTag, closeTag) {
 function detailsTokenizer(src) {
 	const detailsRegex = /^<details>\n/;
 	const summaryRegex = /^<summary>(.*?)<\/summary>\n/;
+	const loadingRegex = /<loading\s*\/>/; // Detect <loading/>
 
 	if (detailsRegex.test(src)) {
 		const endIndex = findMatchingClosingTag(src, '<details>', '</details>');
 		if (endIndex === -1) return;
-
 		const fullMatch = src.slice(0, endIndex);
 		let content = fullMatch.slice(10, -10).trim(); // Remove <details> and </details>
-
 		let summary = '';
+		let isLoading = false;
+
 		const summaryMatch = summaryRegex.exec(content);
 		if (summaryMatch) {
 			summary = summaryMatch[1].trim();
+
+			// Detect and remove <loading/>
+			if (loadingRegex.test(summary)) {
+				isLoading = true;
+				summary = summary.replace(loadingRegex, '').trim(); // Remove <loading/> from summary
+			}
+
 			content = content.slice(summaryMatch[0].length).trim();
 		}
 
@@ -37,7 +45,8 @@ function detailsTokenizer(src) {
 			type: 'details',
 			raw: fullMatch,
 			summary: summary,
-			text: content
+			text: content,
+			isLoading: isLoading // Include loading property to indicate if <loading/> was present
 		};
 	}
 }