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
|
// 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 {
|
if err != nil {
|
||||||
log.WithError(err).WithField("chat_id", chat.ID).Error("failed to start message generation")
|
log.WithError(err).WithField("chat_id", chat.ID).Error("failed to start message generation")
|
||||||
http.Error(w, "Failed to start message generation", http.StatusInternalServerError)
|
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
|
// 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 {
|
if err != nil {
|
||||||
log.WithError(err).WithField("chat_id", chatID).Error("failed to start message generation")
|
log.WithError(err).WithField("chat_id", chatID).Error("failed to start message generation")
|
||||||
if errors.Is(err, errGenerationActive) {
|
if errors.Is(err, errGenerationActive) {
|
||||||
@@ -572,7 +572,7 @@ func (a *API) getClient() (*client.Client, error) {
|
|||||||
return a.client, nil
|
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()
|
apiClient, err := a.getClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
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
|
return nil
|
||||||
}, func(gen *generation) {
|
}, func(gen *generation) {
|
||||||
a.runMessageGeneration(apiClient, chat, assistantMsg, chatModel, gen)
|
a.runMessageGeneration(apiClient, chat, assistantMsg, chatModel, enableThinking, gen)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -621,13 +621,30 @@ func (a *API) startMessageGeneration(chatID uuid.UUID, chatModel, userMessage st
|
|||||||
return initialChunk, nil
|
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
|
// Create Generation Context
|
||||||
ctx, cancel := context.WithTimeout(gen.ctx, a.textGenerationTimeout())
|
ctx, cancel := context.WithTimeout(gen.ctx, a.textGenerationTimeout())
|
||||||
defer cancel()
|
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
|
// 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
|
messageChanged := false
|
||||||
|
|
||||||
if m.Stats != nil {
|
if m.Stats != nil {
|
||||||
@@ -684,17 +701,6 @@ func (a *API) runMessageGeneration(apiClient *client.Client, chat *store.Chat, a
|
|||||||
return
|
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
|
// Complete Assistant Message
|
||||||
assistantMsg.Status = store.MessageStatusComplete
|
assistantMsg.Status = store.MessageStatusComplete
|
||||||
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
if err := a.store.SaveChatMessage(assistantMsg); err != nil {
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ type GenerateTextRequest struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
Images []string `json:"images,omitempty"`
|
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 {
|
func (r *GenerateTextRequest) Validate() error {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (c *Client) EditImage(ctx context.Context, body openai.ImageEditParams) ([]
|
|||||||
return resp.Data, nil
|
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
|
// Ensure Callback
|
||||||
if cb == nil {
|
if cb == nil {
|
||||||
cb = func(mc *MessageChunk) error { return nil }
|
cb = func(mc *MessageChunk) error { return nil }
|
||||||
@@ -90,6 +90,7 @@ func (c *Client) SendMessage(ctx context.Context, chatMessages []*store.Message,
|
|||||||
}
|
}
|
||||||
chatReq.SetExtraFields(map[string]any{
|
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
|
// 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{{
|
output, err := c.SendMessage(ctx, []*store.Message{{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: prompt,
|
Content: prompt,
|
||||||
}}, model, nil)
|
}}, model, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to sent message: %w", err)
|
return "", fmt.Errorf("failed to sent message: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestSendMessage(t *testing.T) {
|
|||||||
_, err = client.SendMessage(ctx, []*store.Message{{
|
_, err = client.SendMessage(ctx, []*store.Message{{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: "What is 2+2? Think step by step.",
|
Content: "What is 2+2? Think step by step.",
|
||||||
}}, model, func(mc *MessageChunk) error {
|
}}, model, true, func(mc *MessageChunk) error {
|
||||||
if mc.Thinking != nil {
|
if mc.Thinking != nil {
|
||||||
_, err := thinkingBuf.Write([]byte(*mc.Thinking))
|
_, err := thinkingBuf.Write([]byte(*mc.Thinking))
|
||||||
return err
|
return err
|
||||||
@@ -118,7 +118,7 @@ func TestSendMessageWithImage(t *testing.T) {
|
|||||||
Role: "user",
|
Role: "user",
|
||||||
Content: "Describe this image in detail.",
|
Content: "Describe this image in detail.",
|
||||||
Images: []string{dataURL},
|
Images: []string{dataURL},
|
||||||
}}, model, func(mc *MessageChunk) error {
|
}}, model, true, func(mc *MessageChunk) error {
|
||||||
if mc.Message != nil {
|
if mc.Message != nil {
|
||||||
outputBuf.WriteString(*mc.Message)
|
outputBuf.WriteString(*mc.Message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<div x-data="chatManager()">
|
<div x-data="chatManager()">
|
||||||
<!-- Chat Content -->
|
<!-- Chat Content -->
|
||||||
<div
|
<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">
|
<template x-for="message in currentChatMessages" :key="message.id">
|
||||||
<div
|
<div
|
||||||
@@ -138,100 +139,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Input and Model Selection -->
|
<!-- 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="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
|
||||||
<div
|
<form
|
||||||
class="flex flex-col gap-3 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
|
@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>
|
|
||||||
|
|
||||||
<!-- Image Preview Strip -->
|
|
||||||
<div
|
|
||||||
x-show="selectedImages.length > 0"
|
|
||||||
class="flex gap-2 flex-wrap"
|
|
||||||
>
|
|
||||||
<template x-for="(img, idx) in selectedImages" :key="idx">
|
|
||||||
<div class="relative">
|
|
||||||
<img :src="img" class="w-20 h-20 object-cover rounded-lg" />
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message Form -->
|
|
||||||
<form @submit.prevent="sendMessage" class="flex gap-2 items-end">
|
|
||||||
<!-- Attach Image 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"
|
|
||||||
title="Attach Image"
|
|
||||||
>
|
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Hidden File Input -->
|
<!-- Hidden File Input -->
|
||||||
<input
|
<input
|
||||||
x-ref="fileInput"
|
x-ref="fileInput"
|
||||||
@@ -249,33 +165,175 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Image Preview Strip -->
|
||||||
|
<div
|
||||||
|
x-show="selectedImages.length > 0"
|
||||||
|
class="flex gap-2 flex-wrap"
|
||||||
|
>
|
||||||
|
<template x-for="(img, idx) in selectedImages" :key="idx">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Textarea (dominant) -->
|
||||||
<textarea
|
<textarea
|
||||||
x-model="inputMessage"
|
x-model="inputMessage"
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
rows="1"
|
rows="2"
|
||||||
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"
|
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(); }"
|
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); sendMessage(); }"
|
||||||
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
|
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<button
|
<!-- Bottom Toolbar: model badge + actions -->
|
||||||
type="submit"
|
<div class="flex items-center gap-2">
|
||||||
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
|
<!-- Model Badge Select -->
|
||||||
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
|
<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">
|
||||||
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"
|
<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"
|
||||||
>
|
>
|
||||||
<template x-if="loading">
|
<option value="">Select Model</option>
|
||||||
<div
|
<template x-for="model in models" :key="model.id">
|
||||||
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
<option
|
||||||
></div>
|
:value="model.id"
|
||||||
|
x-text="model.name || model.id"
|
||||||
|
></option>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!loading">
|
</select>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
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"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
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="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-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 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'"
|
||||||
|
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-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!loading">
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -285,36 +343,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<div
|
<div
|
||||||
x-show="error"
|
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>
|
<p class="text-sm text-tertiary-700" x-text="error"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -400,7 +438,7 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<template x-for="chat in chats" :key="chat.id">
|
<template x-for="chat in chats" :key="chat.id">
|
||||||
<div
|
<div
|
||||||
@click="selectChat(chat.id); chatListOpen = false;"
|
@click="selectChat(chat.id); if (!window.matchMedia('(min-width: 768px)').matches) chatListOpen = false;"
|
||||||
:class="[
|
:class="[
|
||||||
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
|
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
|
||||||
selectedChatID === chat.id
|
selectedChatID === chat.id
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { applyFilter } from '../utils';
|
|||||||
|
|
||||||
const CHAT_ROUTE = '#/chats';
|
const CHAT_ROUTE = '#/chats';
|
||||||
const MODEL_KEY = 'aethera-chat-model';
|
const MODEL_KEY = 'aethera-chat-model';
|
||||||
|
const THINKING_KEY = 'aethera-chat-thinking';
|
||||||
const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000';
|
const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
// Markdown Renderer
|
// Markdown Renderer
|
||||||
@@ -37,12 +38,13 @@ Alpine.data('chatManager', () => ({
|
|||||||
_models: [] as Model[],
|
_models: [] as Model[],
|
||||||
|
|
||||||
selectedModel: '',
|
selectedModel: '',
|
||||||
|
thinkingEnabled: localStorage.getItem(THINKING_KEY) !== 'false',
|
||||||
inputMessage: '',
|
inputMessage: '',
|
||||||
selectedImages: [] as string[],
|
selectedImages: [] as string[],
|
||||||
error: '',
|
error: '',
|
||||||
|
|
||||||
selectedChatID: null as string | null,
|
selectedChatID: null as string | null,
|
||||||
chatListOpen: false,
|
chatListOpen: typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches,
|
||||||
loading: false,
|
loading: false,
|
||||||
activeStreamChatID: null as string | null,
|
activeStreamChatID: null as string | null,
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ Alpine.data('chatManager', () => ({
|
|||||||
|
|
||||||
// Save Model
|
// Save Model
|
||||||
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
|
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
|
||||||
|
localStorage.setItem(THINKING_KEY, String(this.thinkingEnabled));
|
||||||
|
|
||||||
// New Chat
|
// New Chat
|
||||||
if (!this.selectedChatID) {
|
if (!this.selectedChatID) {
|
||||||
@@ -145,7 +148,7 @@ Alpine.data('chatManager', () => ({
|
|||||||
try {
|
try {
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
|
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
|
||||||
{ model: this.selectedModel, prompt: message, images },
|
{ model: this.selectedModel, prompt: message, images, thinking: this.thinkingEnabled },
|
||||||
(chunk: MessageChunk) => {
|
(chunk: MessageChunk) => {
|
||||||
if (chunk.chat) this.activeStreamChatID = chunk.chat.id;
|
if (chunk.chat) this.activeStreamChatID = chunk.chat.id;
|
||||||
this.applyMessageChunk(chunk);
|
this.applyMessageChunk(chunk);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface GenerateTextRequest {
|
|||||||
model: string;
|
model: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
thinking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatListResponse {
|
export interface ChatListResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user