Fix: collapsible thinking display and separate deep research retrieval tag (#14613)

## Summary

- **Collapsible thinking**: Replace `<section>` with `<details>` for
`<think>` content, so model thinking output is collapsed by default
(click to expand). Works for all models that output `<think>` tags
(Qwen3, DeepSeek, Gemini, Claude, etc.).
- **Fix double thinking tags**: When reasoning/deep research mode is
enabled in knowledge base chat, both the retrieval progress and model
thinking were wrapped in `<think>` tags, producing two "Thinking..."
blocks. Now retrieval progress uses a dedicated `<retrieving>` tag
rendered as a separate "Retrieving..." collapsible with a distinct green
accent.

### Before
- Thinking content displayed as flat gray-bordered `<section>`,
occupying significant screen space
- Deep research + model thinking both use `<think>` → two identical
"Thinking..." blocks

### After
- Thinking content collapsed by default in a `<details>` element, click
"Thinking..." to expand
- Deep research shows "Retrieving..." (green border), model thinking
shows "Thinking..." (gray border)

## Changes

**Backend (`api/db/services/dialog_service.py`)**
- Deep research callback: replace `start_to_think`/`end_to_think` marker
flags with direct `<retrieving>`/`</retrieving>` answer text

**Frontend**
- `web/src/utils/chat.ts`: `replaceThinkToSection()` now uses
`<details>` instead of `<section>`; add new
`replaceRetrievingToSection()`
- 4 tsx files: import and pipe `replaceRetrievingToSection`, whitelist
`details`, `summary`, `retrieving` in DOMPurify `ADD_TAGS`
- 4 less files: `section.think` → `details.think` with `<summary>`
styles; add `details.retrieving` with green accent; dark mode and RTL
variants

## Test plan
- [ ] Open a chat WITHOUT knowledge base, ask a question to a model with
thinking (e.g. Qwen3) → thinking content should be collapsed by default,
click "Thinking..." to expand
- [ ] Open a chat WITH knowledge base and reasoning enabled, ask a
question → "Retrieving..." (green) shows retrieval progress,
"Thinking..." (gray) shows model thinking, each independently
collapsible
- [ ] Verify dark mode renders correctly for both collapsible blocks
- [ ] Verify RTL layout renders correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: wanghualoong <wanghualoong@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tim Wang
2026-05-08 14:40:00 +08:00
committed by GitHub
parent d51fb88573
commit 1bcb6deb6f
10 changed files with 126 additions and 15 deletions

View File

@@ -665,9 +665,9 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
while True:
msg = await queue.get()
if msg.find("<START_DEEP_RESEARCH>") == 0:
yield {"answer": "", "reference": {}, "audio_binary": None, "final": False, "start_to_think": True}
yield {"answer": "<retrieving>", "reference": {}, "audio_binary": None, "final": False}
elif msg.find("<END_DEEP_RESEARCH>") == 0:
yield {"answer": "", "reference": {}, "audio_binary": None, "final": False, "end_to_think": True}
yield {"answer": "</retrieving>", "reference": {}, "audio_binary": None, "final": False}
break
else:
yield {"answer": msg, "reference": {}, "audio_binary": None, "final": False}

View File

@@ -10,6 +10,7 @@ import {
currentReg,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
showImage,
@@ -66,7 +67,7 @@ const FloatingChatWidgetMarkdown = ({
const contentWithCursor = useMemo(() => {
const text = content === '' ? t('chat.searching') : content;
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
return pipe(replaceThinkToSection, replaceRetrievingToSection, preprocessLaTeX)(nextText);
}, [content, t]);
useEffect(() => {

View File

@@ -1,10 +1,37 @@
.markdownContentWrapper {
:global(section.think) {
:global(details.think) {
padding-inline-start: 10px;
color: #8b8b8b;
border-inline-start: 2px solid #d5d3d3;
margin-bottom: 10px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 500;
color: #999;
user-select: none;
&:hover {
color: #666;
}
}
}
:global(details.retrieving) {
padding-inline-start: 10px;
color: #8b8b8b;
border-inline-start: 2px solid #a3d5c9;
margin-bottom: 10px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 500;
color: #6ba89a;
user-select: none;
&:hover {
color: #4a8a7c;
}
}
}
:global(blockquote) {
padding-inline-start: 10px;

View File

@@ -23,6 +23,7 @@ import {
currentReg,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
} from '@/utils/chat';
@@ -63,7 +64,7 @@ const MarkdownContent = ({
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
let text = DOMPurify.sanitize(content, {
ADD_TAGS: ['think', 'section'],
ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'],
ADD_ATTR: ['class'],
});
@@ -72,7 +73,7 @@ const MarkdownContent = ({
text = t('chat.searching');
}
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
return pipe(replaceThinkToSection, replaceRetrievingToSection, preprocessLaTeX)(nextText);
}, [content, t]);
useEffect(() => {

View File

@@ -34,9 +34,27 @@
.messageTextBase();
padding: 0;
word-break: break-word;
:global(section.think) {
:global(details.think) {
color: rgb(166, 166, 166);
border-inline-start-color: rgb(78, 78, 86);
summary {
color: rgb(140, 140, 140);
&:hover {
color: rgb(180, 180, 180);
}
}
}
:global(details.retrieving) {
color: rgb(166, 166, 166);
border-inline-start-color: rgb(60, 100, 90);
summary {
color: rgb(120, 170, 155);
&:hover {
color: rgb(150, 200, 185);
}
}
}
}

View File

@@ -1,10 +1,37 @@
.markdownContentWrapper {
:global(section.think) {
:global(details.think) {
padding-inline-start: 10px;
color: #8b8b8b;
border-inline-start: 2px solid #d5d3d3;
margin-bottom: 10px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 500;
color: #999;
user-select: none;
&:hover {
color: #666;
}
}
}
:global(details.retrieving) {
padding-inline-start: 10px;
color: #8b8b8b;
border-inline-start: 2px solid #a3d5c9;
margin-bottom: 10px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 500;
color: #6ba89a;
user-select: none;
&:hover {
color: #4a8a7c;
}
}
}
:global(blockquote) {
padding-inline-start: 10px;

View File

@@ -22,6 +22,7 @@ import {
currentReg,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
} from '@/utils/chat';
@@ -170,7 +171,7 @@ function MarkdownContent({
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
let text = DOMPurify.sanitize(content, {
ADD_TAGS: ['think', 'section'],
ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'],
ADD_ATTR: ['class'],
});
// let text = content;
@@ -178,7 +179,7 @@ function MarkdownContent({
text = t('chat.searching');
}
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
return pipe(replaceThinkToSection, replaceRetrievingToSection, preprocessLaTeX)(nextText);
}, [content, t]);
useEffect(() => {

View File

@@ -37,16 +37,34 @@
.chunkText();
.messageTextBase();
word-break: break-word;
:global(section.think) {
:global(details.think) {
color: rgb(166, 166, 166);
border-inline-start-color: rgb(78, 78, 86);
summary {
color: rgb(140, 140, 140);
&:hover {
color: rgb(180, 180, 180);
}
}
}
:global(details.retrieving) {
color: rgb(166, 166, 166);
border-inline-start-color: rgb(60, 100, 90);
summary {
color: rgb(120, 170, 155);
&:hover {
color: rgb(150, 200, 185);
}
}
}
// RTL Support
&[dir='rtl'] {
text-align: right;
:global(section.think) {
:global(details.think) {
border-inline-start-color: transparent;
border-inline-end-color: rgb(78, 78, 86);
border-inline-end-width: 2px;
@@ -55,6 +73,15 @@
padding-inline-start: 0;
padding-inline-end: 10px;
}
:global(details.retrieving) {
border-inline-start-color: transparent;
border-inline-end-color: rgb(60, 100, 90);
border-inline-end-width: 2px;
border-inline-end-style: solid;
border-inline-start: none;
padding-inline-start: 0;
padding-inline-end: 10px;
}
}
}

View File

@@ -20,6 +20,7 @@ import {
currentReg,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
} from '@/utils/chat';
@@ -67,7 +68,7 @@ const MarkdownContent = ({
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
let text = DOMPurify.sanitize(content, {
ADD_TAGS: ['think', 'section'],
ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'],
ADD_ATTR: ['class'],
});
// let text = content;
@@ -75,7 +76,7 @@ const MarkdownContent = ({
text = t('chat.searching');
}
const nextText = replaceTextByOldReg(text);
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
return pipe(replaceThinkToSection, replaceRetrievingToSection, preprocessLaTeX)(nextText);
}, [content, t]);
useEffect(() => {

View File

@@ -81,7 +81,15 @@ export const preprocessLaTeX = (content: string) => {
export function replaceThinkToSection(text: string = '') {
const pattern = /<think>([\s\S]*?)<\/think>/g;
const result = text.replace(pattern, '<section class="think">$1</section>');
const result = text.replace(pattern, '<details class="think"><summary>Thinking...</summary>$1</details>');
return result;
}
export function replaceRetrievingToSection(text: string = '') {
const pattern = /<retrieving>([\s\S]*?)<\/retrieving>/g;
const result = text.replace(pattern, '<details class="retrieving"><summary>Retrieving...</summary>$1</details>');
return result;
}