feat(chat): redesign input bar, expand sidebar, add thinking toggle

- Restructure floating input: dominant textarea with compact bottom
  toolbar (model badge, thinking toggle, attach, send/stop).
- Model badge sizes to the current selection (not widest option) via
  a layered transparent select, with truncate-on-overflow fallback.
- Auto-expand the conversation sidebar on desktop and slide chat
  content right when open instead of overlaying.
- Add per-request thinking toggle (brain icon, default on, persisted
  in localStorage) sending chat_template_kwargs.enable_thinking.
- Always disable thinking for title summarization.
- Generate chat titles before the main response to keep the SSE
  stream from staying open past visible completion and to avoid
  busting the KV cache between turns.
This commit is contained in:
2026-05-17 16:36:11 -04:00
parent 8f732e6fc7
commit 6307a64c9c
7 changed files with 197 additions and 140 deletions

View File

@@ -345,7 +345,7 @@ func (a *API) PostChat(w http.ResponseWriter, r *http.Request) {
}
// Start Message
chunk, err := a.startMessageGeneration(chat.ID, genReq.Model, genReq.Prompt, genReq.Images)
chunk, err := a.startMessageGeneration(chat.ID, genReq.Model, genReq.Prompt, genReq.Images, genReq.EnableThinking())
if err != nil {
log.WithError(err).WithField("chat_id", chat.ID).Error("failed to start message generation")
http.Error(w, "Failed to start message generation", http.StatusInternalServerError)
@@ -539,7 +539,7 @@ func (a *API) PostChatMessage(w http.ResponseWriter, r *http.Request) {
}
// Start Message
chunk, err := a.startMessageGeneration(chatID, genReq.Model, genReq.Prompt, genReq.Images)
chunk, err := a.startMessageGeneration(chatID, genReq.Model, genReq.Prompt, genReq.Images, genReq.EnableThinking())
if err != nil {
log.WithError(err).WithField("chat_id", chatID).Error("failed to start message generation")
if errors.Is(err, errGenerationActive) {
@@ -572,7 +572,7 @@ func (a *API) getClient() (*client.Client, error) {
return a.client, nil
}
func (a *API) startMessageGeneration(chatID uuid.UUID, chatModel, userMessage string, images []string) (*MessageChunk, error) {
func (a *API) startMessageGeneration(chatID uuid.UUID, chatModel, userMessage string, images []string, enableThinking bool) (*MessageChunk, error) {
apiClient, err := a.getClient()
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
@@ -613,7 +613,7 @@ func (a *API) startMessageGeneration(chatID uuid.UUID, chatModel, userMessage st
}
return nil
}, func(gen *generation) {
a.runMessageGeneration(apiClient, chat, assistantMsg, chatModel, gen)
a.runMessageGeneration(apiClient, chat, assistantMsg, chatModel, enableThinking, gen)
}); err != nil {
return nil, err
}
@@ -621,13 +621,30 @@ func (a *API) startMessageGeneration(chatID uuid.UUID, chatModel, userMessage st
return initialChunk, nil
}
func (a *API) runMessageGeneration(apiClient *client.Client, chat *store.Chat, assistantMsg *store.Message, chatModel string, gen *generation) {
func (a *API) runMessageGeneration(apiClient *client.Client, chat *store.Chat, assistantMsg *store.Message, chatModel string, enableThinking bool, gen *generation) {
// Create Generation Context
ctx, cancel := context.WithTimeout(gen.ctx, a.textGenerationTimeout())
defer cancel()
// Generate Title First - Doing this before the main response avoids busting the KV
// cache between the user prompt and the assistant reply, and keeps the stream from
// staying open past the visible response completing.
if chat.Title == "" && len(chat.Messages) > 0 {
title, err := apiClient.CreateTitle(ctx, chat.Messages[0].Content, chatModel)
if err != nil {
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to create chat title")
} else {
chat.Title = title
if err := a.store.SaveChat(chat); err != nil {
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to update chat")
} else {
gen.broadcast(&MessageChunk{Chat: toChatNoMessages(chat)})
}
}
}
// Send Message
if _, err := apiClient.SendMessage(ctx, chat.Messages, chatModel, func(m *client.MessageChunk) error {
if _, err := apiClient.SendMessage(ctx, chat.Messages, chatModel, enableThinking, func(m *client.MessageChunk) error {
messageChanged := false
if m.Stats != nil {
@@ -684,17 +701,6 @@ func (a *API) runMessageGeneration(apiClient *client.Client, chat *store.Chat, a
return
}
// Summarize & Update Chat Title
if chat.Title == "" {
var err error
chat.Title, err = apiClient.CreateTitle(ctx, chat.Messages[0].Content, chatModel)
if err != nil {
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to create chat title")
} else if err := a.store.SaveChat(chat); err != nil {
a.logger.WithError(err).WithField("chat_id", chat.ID).Error("failed to update chat")
}
}
// Complete Assistant Message
assistantMsg.Status = store.MessageStatusComplete
if err := a.store.SaveChatMessage(assistantMsg); err != nil {

View File

@@ -69,9 +69,17 @@ type ImageRecord struct {
}
type GenerateTextRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Images []string `json:"images,omitempty"`
Model string `json:"model"`
Prompt string `json:"prompt"`
Images []string `json:"images,omitempty"`
Thinking *bool `json:"thinking,omitempty"`
}
func (r *GenerateTextRequest) EnableThinking() bool {
if r.Thinking == nil {
return true
}
return *r.Thinking
}
func (r *GenerateTextRequest) Validate() error {

View File

@@ -66,7 +66,7 @@ func (c *Client) EditImage(ctx context.Context, body openai.ImageEditParams) ([]
return resp.Data, nil
}
func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message, model string, cb StreamCallback) (string, error) {
func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message, model string, enableThinking bool, cb StreamCallback) (string, error) {
// Ensure Callback
if cb == nil {
cb = func(mc *MessageChunk) error { return nil }
@@ -89,7 +89,8 @@ func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message,
},
}
chatReq.SetExtraFields(map[string]any{
"timings_per_token": true, // Llama.cpp
"timings_per_token": true, // Llama.cpp
"chat_template_kwargs": map[string]any{"enable_thinking": enableThinking},
})
// Perform Request & Allocate Stats
@@ -172,7 +173,7 @@ func (c *Client) CreateTitle(ctx context.Context, userMessage, model string) (st
output, err := c.SendMessage(ctx, []*store.Message{{
Role: "user",
Content: prompt,
}}, model, nil)
}}, model, false, nil)
if err != nil {
return "", fmt.Errorf("failed to sent message: %w", err)
}

View File

@@ -32,7 +32,7 @@ func TestSendMessage(t *testing.T) {
_, err = client.SendMessage(ctx, []*store.Message{{
Role: "user",
Content: "What is 2+2? Think step by step.",
}}, model, func(mc *MessageChunk) error {
}}, model, true, func(mc *MessageChunk) error {
if mc.Thinking != nil {
_, err := thinkingBuf.Write([]byte(*mc.Thinking))
return err
@@ -118,7 +118,7 @@ func TestSendMessageWithImage(t *testing.T) {
Role: "user",
Content: "Describe this image in detail.",
Images: []string{dataURL},
}}, model, func(mc *MessageChunk) error {
}}, model, true, func(mc *MessageChunk) error {
if mc.Message != nil {
outputBuf.WriteString(*mc.Message)
}

View File

@@ -1,7 +1,8 @@
<div x-data="chatManager()">
<!-- Chat Content -->
<div
class="h-dvh pt-16 flex flex-col-reverse pb-36 overflow-scroll mx-auto px-4 md:px-6 max-w-6xl"
:class="chatListOpen ? 'md:pl-[23rem]' : ''"
class="h-dvh pt-16 flex flex-col-reverse pb-36 overflow-scroll mx-auto px-4 md:px-6 max-w-6xl transition-all duration-300 ease-out"
>
<template x-for="message in currentChatMessages" :key="message.id">
<div
@@ -138,56 +139,31 @@
</div>
<!-- Floating Input and Model Selection -->
<div class="fixed bottom-4 w-full flex justify-center px-4 md:px-6">
<div
:class="chatListOpen ? 'md:pl-[23rem]' : ''"
class="fixed bottom-4 w-full flex justify-center px-4 md:px-6 transition-all duration-300 ease-out"
>
<div class="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
<div
class="flex flex-col gap-3 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
<form
@submit.prevent="sendMessage"
class="flex flex-col gap-2 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
>
<!-- Model Select -->
<div class="relative">
<select
x-model="selectedModel"
class="w-full appearance-none px-9 py-3 bg-gradient-to-r from-primary-50 to-primary-300 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm font-medium cursor-pointer transition-shadow hover:shadow-md"
>
<option value="">Select Model</option>
<template x-for="model in models" :key="model.id">
<option
:value="model.id"
x-text="model.name || model.id"
></option>
</template>
</select>
<!-- Computer Icon -->
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<!-- Chevron Icon -->
<svg
class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-primary-400 pointer-events-none transition-colors hover:text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
<!-- Hidden File Input -->
<input
x-ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/webp"
multiple
class="hidden"
@change="
Array.from($event.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => { selectedImages.push(e.target.result); };
reader.readAsDataURL(file);
});
$event.target.value = '';
"
/>
<!-- Image Preview Strip -->
<div
@@ -198,6 +174,7 @@
<div class="relative">
<img :src="img" class="w-20 h-20 object-cover rounded-lg" />
<button
type="button"
@click="selectedImages.splice(idx, 1)"
class="absolute -top-1 -right-1 w-5 h-5 bg-tertiary-700 text-white rounded-full flex items-center justify-center text-xs hover:bg-tertiary-900"
>
@@ -207,16 +184,109 @@
</template>
</div>
<!-- Message Form -->
<form @submit.prevent="sendMessage" class="flex gap-2 items-end">
<!-- Attach Image Button -->
<!-- Message Textarea (dominant) -->
<textarea
x-model="inputMessage"
placeholder="Type your message..."
rows="2"
class="scrollbar-hide w-full px-2 py-1 bg-transparent border-0 focus:outline-none focus:ring-0 text-primary-900 text-sm resize-none overflow-y-auto max-h-60"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); sendMessage(); }"
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
></textarea>
<!-- Bottom Toolbar: model badge + actions -->
<div class="flex items-center gap-2">
<!-- Model Badge Select -->
<div class="relative min-w-0 flex-shrink inline-flex items-center pl-7 pr-6 py-1 bg-primary-200/70 hover:bg-primary-300 border border-primary-200 rounded-full transition-colors">
<span
class="text-primary-900 text-xs font-medium truncate pointer-events-none"
x-text="(models.find(m => m.id === selectedModel)?.name) || selectedModel || 'Select Model'"
></span>
<select
x-model="selectedModel"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer focus:outline-none"
aria-label="Select model"
>
<option value="">Select Model</option>
<template x-for="model in models" :key="model.id">
<option
:value="model.id"
x-text="model.name || model.id"
></option>
</template>
</select>
<svg
class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary-500 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<svg
class="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-primary-500 pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
<!-- Thinking Toggle Badge -->
<button
type="button"
@click="thinkingEnabled = !thinkingEnabled"
:class="thinkingEnabled
? 'bg-primary-600 text-white border-primary-600 hover:bg-primary-700'
: 'bg-transparent text-primary-500 border-primary-300 hover:bg-primary-200/70'"
class="relative h-7 w-7 rounded-full border transition-colors flex items-center justify-center flex-shrink-0"
:title="thinkingEnabled ? 'Thinking on' : 'Thinking off'"
:aria-pressed="thinkingEnabled ? 'true' : 'false'"
aria-label="Toggle thinking"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 0 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 0 0 12 21z" />
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 0 1 12 21" />
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M19.967 17.484A4 4 0 0 1 18 18" />
</svg>
<svg
x-show="!thinkingEnabled"
class="absolute inset-0 h-full w-full text-primary-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden="true"
>
<line x1="5" y1="19" x2="19" y2="5" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<!-- Attach Image Badge Button -->
<button
type="button"
@click="$refs.fileInput.click()"
class="self-stretch w-[44px] bg-primary-200 text-primary-700 rounded-xl transition-all flex items-center justify-center flex-shrink-0 hover:bg-primary-300 hover:shadow-md"
class="h-7 w-7 bg-primary-200/70 text-primary-700 rounded-full transition-colors flex items-center justify-center flex-shrink-0 hover:bg-primary-300"
title="Attach Image"
aria-label="Attach Image"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -232,50 +302,38 @@
</svg>
</button>
<!-- Hidden File Input -->
<input
x-ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/webp"
multiple
class="hidden"
@change="
Array.from($event.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => { selectedImages.push(e.target.result); };
reader.readAsDataURL(file);
});
$event.target.value = '';
"
/>
<textarea
x-model="inputMessage"
placeholder="Type your message..."
rows="1"
class="scrollbar-hide flex-1 p-3 bg-primary-50 border border-primary-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-400 text-primary-900 text-sm transition-shadow hover:bg-primary-100 resize-none overflow-y-auto max-h-60"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); sendMessage(); }"
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
></textarea>
<!-- Stop Button -->
<button
x-show="loading"
type="button"
@click="stopResponse()"
:disabled="!activeStreamChatID"
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:bg-tertiary-700'"
class="ml-auto h-7 w-7 bg-tertiary-600 text-white rounded-full transition-colors flex items-center justify-center flex-shrink-0"
title="Stop response"
aria-label="Stop response"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6h12v12H6z" />
</svg>
</button>
<!-- Send Button -->
<button
type="submit"
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
class="self-stretch w-[44px] bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-xl transition-all flex items-center justify-center flex-shrink-0"
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md'"
class="ml-auto h-7 w-7 bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-full transition-all flex items-center justify-center flex-shrink-0"
title="Send"
aria-label="Send"
>
<template x-if="loading">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
<div class="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
</template>
<template x-if="!loading">
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -285,36 +343,16 @@
</svg>
</template>
</button>
<button
x-show="loading"
type="button"
@click="stopResponse()"
:disabled="!activeStreamChatID"
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
class="self-stretch w-[44px] bg-tertiary-600 text-white rounded-xl transition-all flex items-center justify-center flex-shrink-0"
title="Stop response"
aria-label="Stop response"
>
<svg
class="h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 6h12v12H6z" />
</svg>
</button>
</form>
</div>
<!-- Error Message -->
<div
x-show="error"
class="bg-tertiary-50 border border-tertiary-200 px-4 py-2"
class="bg-tertiary-50 border border-tertiary-200 rounded-lg px-4 py-2"
>
<p class="text-sm text-tertiary-700" x-text="error"></p>
</div>
</div>
</form>
</div>
</div>
@@ -400,7 +438,7 @@
<div class="space-y-2">
<template x-for="chat in chats" :key="chat.id">
<div
@click="selectChat(chat.id); chatListOpen = false;"
@click="selectChat(chat.id); if (!window.matchMedia('(min-width: 768px)').matches) chatListOpen = false;"
:class="[
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
selectedChatID === chat.id

View File

@@ -17,6 +17,7 @@ import { applyFilter } from '../utils';
const CHAT_ROUTE = '#/chats';
const MODEL_KEY = 'aethera-chat-model';
const THINKING_KEY = 'aethera-chat-thinking';
const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000';
// Markdown Renderer
@@ -37,12 +38,13 @@ Alpine.data('chatManager', () => ({
_models: [] as Model[],
selectedModel: '',
thinkingEnabled: localStorage.getItem(THINKING_KEY) !== 'false',
inputMessage: '',
selectedImages: [] as string[],
error: '',
selectedChatID: null as string | null,
chatListOpen: false,
chatListOpen: typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches,
loading: false,
activeStreamChatID: null as string | null,
@@ -113,6 +115,7 @@ Alpine.data('chatManager', () => ({
// Save Model
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
localStorage.setItem(THINKING_KEY, String(this.thinkingEnabled));
// New Chat
if (!this.selectedChatID) {
@@ -145,7 +148,7 @@ Alpine.data('chatManager', () => ({
try {
await sendMessage(
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
{ model: this.selectedModel, prompt: message, images },
{ model: this.selectedModel, prompt: message, images, thinking: this.thinkingEnabled },
(chunk: MessageChunk) => {
if (chunk.chat) this.activeStreamChatID = chunk.chat.id;
this.applyMessageChunk(chunk);

View File

@@ -71,6 +71,7 @@ export interface GenerateTextRequest {
model: string;
prompt: string;
images?: string[];
thinking?: boolean;
}
export interface ChatListResponse {