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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface GenerateTextRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
images?: string[];
|
||||
thinking?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatListResponse {
|
||||
|
||||
Reference in New Issue
Block a user