Skip to content

Commit

Permalink
feat: add dedicated UI for reasoning model responses (#261)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
fmaclen authored Jan 26, 2025
1 parent a710a53 commit bc50b03
Show file tree
Hide file tree
Showing 15 changed files with 190 additions and 35 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A minimal web-UI for talking to [Ollama](https://github.com/jmorganca/ollama/) s
- Support for **Ollama** & **OpenAI** models
- Multi-server support
- Large prompt fields
- Support for reasoning models
- Markdown rendering with syntax highlighting
- Code editor features
- Customizable system prompts & advanced Ollama parameters
Expand Down
34 changes: 19 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@playwright/test": "^1.43.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-cloudflare": "^4.7.4",
"@sveltejs/adapter-node": "^5.2.9",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const en = {
pullModelPlaceholder: 'Model tag (e.g. llama3.1)',
pullingModel: 'Pulling model',
random: 'Random',
reasoning: 'Reasoning',
refreshToUpdate: 'Refresh to update',
releaseHistory: 'Release history',
repeatLastN: 'Repeat last N',
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/i18n-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ type RootTranslation = {
* R​a​n​d​o​m
*/
random: string
/**
* R​e​a​s​o​n​i​n​g
*/
reasoning: string
/**
* R​e​f​r​e​s​h​ ​t​o​ ​u​p​d​a​t​e
*/
Expand Down Expand Up @@ -1072,6 +1076,10 @@ The completion in progress will stop
* Random
*/
random: () => LocalizedString
/**
* Reasoning
*/
reasoning: () => LocalizedString
/**
* Refresh to update
*/
Expand Down
5 changes: 3 additions & 2 deletions src/lib/chat/openai.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import OpenAI from 'openai';
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs';

import type { Server } from '$lib/servers';
import type { Server } from '$lib/connections';
import type { Model } from '$lib/settings';

import type { ChatRequest, ChatStrategy, Model } from './index';
import type { ChatRequest, ChatStrategy } from './index';

export class OpenAIStrategy implements ChatStrategy {
private openai: OpenAI;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { formatTimestampToNow } from './utils';
export interface Message extends ChatMessage {
knowledge?: Knowledge;
context?: number[];
reasoning?: string;
}

export interface Session {
Expand All @@ -31,6 +32,7 @@ export interface Editor {
isNewSession: boolean;
shouldFocusTextarea: boolean;
completion?: string;
reasoning?: string;
promptTextarea?: HTMLTextAreaElement;
abortController?: AbortController;
}
Expand Down
9 changes: 5 additions & 4 deletions src/routes/motd/motd.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
`2024-11-25`
`2025-1-26`

### Message of the day

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

#### What's new?

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

#### Previously, in Hollama

- **Models list can be filtered** by keyword for each server.
- **Servers can be labeled** to help you identify them in the models list.
- **Hallo Welt!** UI is now available in German.
- **OpenAI models** are now _(optionally)_ available in Sessions. Set your own API key in [Settings](/settings)
- **[Knowledge](/knowledge)** can now be used as context at any point in a Session.
- **Model** and **advanced Ollama settings** can be changed at any time on an existing session
Expand Down
44 changes: 39 additions & 5 deletions src/routes/sessions/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
import Messages from './Messages.svelte';
import Prompt from './Prompt.svelte';
const THINK_TAG = '<think>';
const END_THINK_TAG = '</think>';
interface Props {
data: PageData;
}
Expand Down Expand Up @@ -135,8 +138,9 @@
async function handleCompletion(messages: Message[]) {
editor.abortController = new AbortController();
editor.isCompletionInProgress = true;
editor.prompt = ''; // Reset the prompt form field
editor.prompt = '';
editor.completion = '';
editor.reasoning = '';
const server = $serversStore.find((s) => s.id === session.model?.serverId);
if (!server) throw new Error('Server not found');
Expand All @@ -161,19 +165,49 @@
}
if (!strategy) throw new Error('Invalid strategy');
let isInThinkTag = false;
await strategy.chat(chatRequest, editor.abortController.signal, async (chunk) => {
editor.completion += chunk;
// This is required primarily for testing, because both the reasoning
// and the completion are returned in a single chunk.
if (chunk.includes(THINK_TAG) && chunk.includes(END_THINK_TAG)) {
const start = chunk.indexOf(THINK_TAG) + THINK_TAG.length;
const end = chunk.indexOf(END_THINK_TAG);
editor.reasoning += chunk.slice(start, end);
chunk = chunk.slice(end);
}
if (chunk.includes(THINK_TAG)) {
isInThinkTag = true;
chunk = chunk.replace(THINK_TAG, '');
}
if (chunk.includes(END_THINK_TAG)) {
isInThinkTag = false;
chunk = chunk.replace(END_THINK_TAG, '');
}
if (isInThinkTag) {
editor.reasoning += chunk;
} else {
editor.completion += chunk;
}
await scrollToBottom();
});
// After the completion save the session
const message: Message = { role: 'assistant', content: editor.completion };
const message: Message = {
role: 'assistant',
content: editor.completion,
reasoning: editor.reasoning
};
session.messages = [...session.messages, message];
session.updatedAt = new Date().toISOString();
saveSession(session);
// Final housekeeping
editor.completion = '';
editor.reasoning = '';
editor.shouldFocusTextarea = true;
editor.isCompletionInProgress = false;
await scrollToBottom();
Expand Down
48 changes: 42 additions & 6 deletions src/routes/sessions/[id]/Article.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
import { BrainIcon, Pencil, RefreshCw, Trash2 } from 'lucide-svelte';
import { BrainIcon, ChevronDown, ChevronUp, Pencil, RefreshCw, Trash2 } from 'lucide-svelte';
import { quadInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import LL from '$i18n/i18n-svelte';
import Badge from '$lib/components/Badge.svelte';
Expand All @@ -18,6 +20,7 @@
let isKnowledgeAttachment: boolean | undefined;
let isUserRole: boolean | undefined;
let isReasoningVisible: boolean = false;
$: if (message) {
isKnowledgeAttachment = message.knowledge?.name !== undefined;
Expand Down Expand Up @@ -84,11 +87,32 @@
</div>
</nav>

<div class="markdown">
{#if message.content}
<Markdown markdown={message.content} />
{/if}
</div>
{#if message.reasoning}
<div class="reasoning" transition:slide={{ easing: quadInOut, duration: 200 }}>
<button
class="reasoning__button"
on:click={() => (isReasoningVisible = !isReasoningVisible)}
>
{$LL.reasoning()}
{#if isReasoningVisible}
<ChevronUp class="base-icon" />
{:else}
<ChevronDown class="base-icon" />
{/if}
</button>
{#if isReasoningVisible}
<article
class="article article--reasoning"
transition:slide={{ easing: quadInOut, duration: 200 }}
>
<Markdown markdown={message.reasoning} />
</article>
{/if}
</div>
{/if}
{#if message.content}
<Markdown markdown={message.content} />
{/if}
</article>
{/if}

Expand All @@ -104,6 +128,10 @@
@apply border-transparent bg-shade-0;
}
.article--reasoning {
@apply max-w-full border-b-0 border-l-0 border-r-0;
}
.article__interactive,
.attachment__interactive {
@apply -mr-2 opacity-100;
Expand Down Expand Up @@ -148,4 +176,12 @@
.attachment__content {
@apply flex items-center gap-2;
}
.reasoning {
@apply rounded bg-shade-1 text-xs;
}
.reasoning__button {
@apply flex w-full items-center justify-between gap-2 p-2;
}
</style>
8 changes: 7 additions & 1 deletion src/routes/sessions/[id]/Messages.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,11 @@
{/each}

{#if editor.isCompletionInProgress}
<Article message={{ role: 'assistant', content: editor.completion || '...' }} />
<Article
message={{
role: 'assistant',
content: editor.completion || '...',
reasoning: editor.reasoning
}}
/>
{/if}
3 changes: 3 additions & 0 deletions tests/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ test('can set ollama model and runtime options', async ({ page }) => {
},
{
role: 'assistant',
reasoning: '',
content: MOCK_SESSION_1_RESPONSE_1.message.content
},
{
Expand Down Expand Up @@ -296,6 +297,7 @@ test('can set ollama model and runtime options', async ({ page }) => {
},
{
role: 'assistant',
reasoning: '',
content: MOCK_SESSION_1_RESPONSE_1.message.content
},
{
Expand All @@ -304,6 +306,7 @@ test('can set ollama model and runtime options', async ({ page }) => {
},
{
role: 'assistant',
reasoning: '',
content: MOCK_SESSION_1_RESPONSE_2.message.content
},
{
Expand Down
6 changes: 5 additions & 1 deletion tests/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,11 @@ test('can use knowledge as system prompt in the session', async ({ page }) => {
messages: [
{ role: 'system', content: MOCK_KNOWLEDGE[0].content, knowledge: MOCK_KNOWLEDGE[0] },
{ role: 'user', content: 'What is this about?' },
{ role: 'assistant', content: MOCK_SESSION_WITH_KNOWLEDGE_RESPONSE_1.message.content },
{
role: 'assistant',
content: MOCK_SESSION_WITH_KNOWLEDGE_RESPONSE_1.message.content,
reasoning: ''
},
{ role: 'user', content: 'Gotcha, thanks for the clarification' }
]
})
Expand Down
Loading

0 comments on commit bc50b03

Please sign in to comment.