Skip to content

Commit bc50b03

Browse files
authored
feat: add dedicated UI for reasoning model responses (#261)
DeepSeek R1 returns it's reasoning process wrapped in `<think></think>` tags. We parse those while the completion is underway and move it's contents to a collapsible UI component.
1 parent a710a53 commit bc50b03

File tree

15 files changed

+190
-35
lines changed

15 files changed

+190
-35
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A minimal web-UI for talking to [Ollama](https://github.com/jmorganca/ollama/) s
77
- Support for **Ollama** & **OpenAI** models
88
- Multi-server support
99
- Large prompt fields
10+
- Support for reasoning models
1011
- Markdown rendering with syntax highlighting
1112
- Code editor features
1213
- Customizable system prompts & advanced Ollama parameters

package-lock.json

Lines changed: 19 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
},
2020
"devDependencies": {
2121
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
22-
"@playwright/test": "^1.43.0",
22+
"@playwright/test": "^1.50.0",
2323
"@sveltejs/adapter-auto": "^3.3.1",
2424
"@sveltejs/adapter-cloudflare": "^4.7.4",
2525
"@sveltejs/adapter-node": "^5.2.9",

src/i18n/en/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const en = {
113113
pullModelPlaceholder: 'Model tag (e.g. llama3.1)',
114114
pullingModel: 'Pulling model',
115115
random: 'Random',
116+
reasoning: 'Reasoning',
116117
refreshToUpdate: 'Refresh to update',
117118
releaseHistory: 'Release history',
118119
repeatLastN: 'Repeat last N',

src/i18n/i18n-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,10 @@ type RootTranslation = {
462462
* R​a​n​d​o​m
463463
*/
464464
random: string
465+
/**
466+
* R​e​a​s​o​n​i​n​g
467+
*/
468+
reasoning: string
465469
/**
466470
* R​e​f​r​e​s​h​ ​t​o​ ​u​p​d​a​t​e
467471
*/
@@ -1072,6 +1076,10 @@ The completion in progress will stop
10721076
* Random
10731077
*/
10741078
random: () => LocalizedString
1079+
/**
1080+
* Reasoning
1081+
*/
1082+
reasoning: () => LocalizedString
10751083
/**
10761084
* Refresh to update
10771085
*/

src/lib/chat/openai.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import OpenAI from 'openai';
22
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs';
33

4-
import type { Server } from '$lib/servers';
4+
import type { Server } from '$lib/connections';
5+
import type { Model } from '$lib/settings';
56

6-
import type { ChatRequest, ChatStrategy, Model } from './index';
7+
import type { ChatRequest, ChatStrategy } from './index';
78

89
export class OpenAIStrategy implements ChatStrategy {
910
private openai: OpenAI;

src/lib/sessions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { formatTimestampToNow } from './utils';
1111
export interface Message extends ChatMessage {
1212
knowledge?: Knowledge;
1313
context?: number[];
14+
reasoning?: string;
1415
}
1516

1617
export interface Session {
@@ -31,6 +32,7 @@ export interface Editor {
3132
isNewSession: boolean;
3233
shouldFocusTextarea: boolean;
3334
completion?: string;
35+
reasoning?: string;
3436
promptTextarea?: HTMLTextAreaElement;
3537
abortController?: AbortController;
3638
}

src/routes/motd/motd.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
`2024-11-25`
1+
`2025-1-26`
22

33
### Message of the day
44

55
# Welcome to Hollama: a simple web interface for [Ollama](https://ollama.ai)
66

77
#### What's new?
88

9+
- **Reasoning responses** (i.e. [`deepseek-r1`](https://ollama.com/library/deepseek-r1)) are now displayed in a dedicated UI component.
910
- **Multiple-server support** allows you to connect to one or more Ollama (and/or OpenAI) servers at the same time.
10-
- **Models list can be filtered** by keyword for each server.
11-
- **Servers can be labeled** to help you identify them in the models list.
12-
- **Hallo Welt!** UI is now available in German.
1311

1412
#### Previously, in Hollama
1513

14+
- **Models list can be filtered** by keyword for each server.
15+
- **Servers can be labeled** to help you identify them in the models list.
16+
- **Hallo Welt!** UI is now available in German.
1617
- **OpenAI models** are now _(optionally)_ available in Sessions. Set your own API key in [Settings](/settings)
1718
- **[Knowledge](/knowledge)** can now be used as context at any point in a Session.
1819
- **Model** and **advanced Ollama settings** can be changed at any time on an existing session

src/routes/sessions/[id]/+page.svelte

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
import Messages from './Messages.svelte';
3131
import Prompt from './Prompt.svelte';
3232
33+
const THINK_TAG = '<think>';
34+
const END_THINK_TAG = '</think>';
35+
3336
interface Props {
3437
data: PageData;
3538
}
@@ -135,8 +138,9 @@
135138
async function handleCompletion(messages: Message[]) {
136139
editor.abortController = new AbortController();
137140
editor.isCompletionInProgress = true;
138-
editor.prompt = ''; // Reset the prompt form field
141+
editor.prompt = '';
139142
editor.completion = '';
143+
editor.reasoning = '';
140144
141145
const server = $serversStore.find((s) => s.id === session.model?.serverId);
142146
if (!server) throw new Error('Server not found');
@@ -161,19 +165,49 @@
161165
}
162166
163167
if (!strategy) throw new Error('Invalid strategy');
168+
169+
let isInThinkTag = false;
164170
await strategy.chat(chatRequest, editor.abortController.signal, async (chunk) => {
165-
editor.completion += chunk;
171+
// This is required primarily for testing, because both the reasoning
172+
// and the completion are returned in a single chunk.
173+
if (chunk.includes(THINK_TAG) && chunk.includes(END_THINK_TAG)) {
174+
const start = chunk.indexOf(THINK_TAG) + THINK_TAG.length;
175+
const end = chunk.indexOf(END_THINK_TAG);
176+
editor.reasoning += chunk.slice(start, end);
177+
chunk = chunk.slice(end);
178+
}
179+
180+
if (chunk.includes(THINK_TAG)) {
181+
isInThinkTag = true;
182+
chunk = chunk.replace(THINK_TAG, '');
183+
}
184+
185+
if (chunk.includes(END_THINK_TAG)) {
186+
isInThinkTag = false;
187+
chunk = chunk.replace(END_THINK_TAG, '');
188+
}
189+
190+
if (isInThinkTag) {
191+
editor.reasoning += chunk;
192+
} else {
193+
editor.completion += chunk;
194+
}
195+
166196
await scrollToBottom();
167197
});
168198
169-
// After the completion save the session
170-
const message: Message = { role: 'assistant', content: editor.completion };
199+
const message: Message = {
200+
role: 'assistant',
201+
content: editor.completion,
202+
reasoning: editor.reasoning
203+
};
204+
171205
session.messages = [...session.messages, message];
172206
session.updatedAt = new Date().toISOString();
173207
saveSession(session);
174208
175-
// Final housekeeping
176209
editor.completion = '';
210+
editor.reasoning = '';
177211
editor.shouldFocusTextarea = true;
178212
editor.isCompletionInProgress = false;
179213
await scrollToBottom();

src/routes/sessions/[id]/Article.svelte

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script lang="ts">
2-
import { BrainIcon, Pencil, RefreshCw, Trash2 } from 'lucide-svelte';
2+
import { BrainIcon, ChevronDown, ChevronUp, Pencil, RefreshCw, Trash2 } from 'lucide-svelte';
3+
import { quadInOut } from 'svelte/easing';
4+
import { slide } from 'svelte/transition';
35
46
import LL from '$i18n/i18n-svelte';
57
import Badge from '$lib/components/Badge.svelte';
@@ -18,6 +20,7 @@
1820
1921
let isKnowledgeAttachment: boolean | undefined;
2022
let isUserRole: boolean | undefined;
23+
let isReasoningVisible: boolean = false;
2124
2225
$: if (message) {
2326
isKnowledgeAttachment = message.knowledge?.name !== undefined;
@@ -84,11 +87,32 @@
8487
</div>
8588
</nav>
8689

87-
<div class="markdown">
88-
{#if message.content}
89-
<Markdown markdown={message.content} />
90-
{/if}
91-
</div>
90+
{#if message.reasoning}
91+
<div class="reasoning" transition:slide={{ easing: quadInOut, duration: 200 }}>
92+
<button
93+
class="reasoning__button"
94+
on:click={() => (isReasoningVisible = !isReasoningVisible)}
95+
>
96+
{$LL.reasoning()}
97+
{#if isReasoningVisible}
98+
<ChevronUp class="base-icon" />
99+
{:else}
100+
<ChevronDown class="base-icon" />
101+
{/if}
102+
</button>
103+
{#if isReasoningVisible}
104+
<article
105+
class="article article--reasoning"
106+
transition:slide={{ easing: quadInOut, duration: 200 }}
107+
>
108+
<Markdown markdown={message.reasoning} />
109+
</article>
110+
{/if}
111+
</div>
112+
{/if}
113+
{#if message.content}
114+
<Markdown markdown={message.content} />
115+
{/if}
92116
</article>
93117
{/if}
94118

@@ -104,6 +128,10 @@
104128
@apply border-transparent bg-shade-0;
105129
}
106130
131+
.article--reasoning {
132+
@apply max-w-full border-b-0 border-l-0 border-r-0;
133+
}
134+
107135
.article__interactive,
108136
.attachment__interactive {
109137
@apply -mr-2 opacity-100;
@@ -148,4 +176,12 @@
148176
.attachment__content {
149177
@apply flex items-center gap-2;
150178
}
179+
180+
.reasoning {
181+
@apply rounded bg-shade-1 text-xs;
182+
}
183+
184+
.reasoning__button {
185+
@apply flex w-full items-center justify-between gap-2 p-2;
186+
}
151187
</style>

0 commit comments

Comments
 (0)