Compare commits
7 Commits
9b77a473b7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 03527e7dc4 | |||
| bf3b308a05 | |||
| 0c002cf5ee | |||
| f5bc4e2ae4 | |||
| eddf5bf12d | |||
| 6307a64c9c | |||
| 8f732e6fc7 |
@@ -44,6 +44,34 @@ func New(s store.Store, dataDir string, logger *logrus.Logger, llmEndpoint, llmK
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSettings(settings *store.Settings) {
|
||||||
|
// Default Text Generation Timeout
|
||||||
|
if settings.TextGenerationTimeoutMinutes == 0 {
|
||||||
|
settings.TextGenerationTimeoutMinutes = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Text Generation Timeout
|
||||||
|
switch settings.TextGenerationTimeoutMinutes {
|
||||||
|
case 1, 5, 10, 15, 30:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
settings.TextGenerationTimeoutMinutes = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) textGenerationTimeout() time.Duration {
|
||||||
|
// Load Settings
|
||||||
|
settings, err := a.store.GetSettings()
|
||||||
|
if err != nil {
|
||||||
|
a.logger.WithError(err).Error("failed to retrieve settings for text generation timeout")
|
||||||
|
return 5 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Timeout
|
||||||
|
normalizeSettings(settings)
|
||||||
|
return time.Duration(settings.TextGenerationTimeoutMinutes) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) {
|
func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
log := a.logger.WithField("handler", "GetSettingsHandler")
|
log := a.logger.WithField("handler", "GetSettingsHandler")
|
||||||
|
|
||||||
@@ -54,6 +82,9 @@ func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize Settings
|
||||||
|
normalizeSettings(settings)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(settings); err != nil {
|
if err := json.NewEncoder(w).Encode(settings); err != nil {
|
||||||
log.WithError(err).Error("failed to encode application settings response")
|
log.WithError(err).Error("failed to encode application settings response")
|
||||||
@@ -72,6 +103,9 @@ func (a *API) PostSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize Settings
|
||||||
|
normalizeSettings(&newSettings)
|
||||||
|
|
||||||
if err := a.store.SaveSettings(&newSettings); err != nil {
|
if err := a.store.SaveSettings(&newSettings); err != nil {
|
||||||
log.WithError(err).Error("failed to save settings")
|
log.WithError(err).Error("failed to save settings")
|
||||||
http.Error(w, "Failed to save application settings", http.StatusInternalServerError)
|
http.Error(w, "Failed to save application settings", http.StatusInternalServerError)
|
||||||
@@ -311,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)
|
||||||
@@ -505,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) {
|
||||||
@@ -538,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)
|
||||||
@@ -579,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
|
||||||
}
|
}
|
||||||
@@ -587,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, time.Minute*5)
|
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 {
|
||||||
@@ -650,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ func TestInMemoryStore_SaveSettings(t *testing.T) {
|
|||||||
ImageEditSelector: ".image-edit",
|
ImageEditSelector: ".image-edit",
|
||||||
ImageGenerationSelector: ".image-gen",
|
ImageGenerationSelector: ".image-gen",
|
||||||
TextGenerationSelector: ".text-gen",
|
TextGenerationSelector: ".text-gen",
|
||||||
|
TextGenerationTimeoutMinutes: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := store.SaveSettings(settings)
|
err := store.SaveSettings(settings)
|
||||||
@@ -163,6 +164,7 @@ func TestInMemoryStore_GetSettings(t *testing.T) {
|
|||||||
ImageEditSelector: ".image-edit",
|
ImageEditSelector: ".image-edit",
|
||||||
ImageGenerationSelector: ".image-gen",
|
ImageGenerationSelector: ".image-gen",
|
||||||
TextGenerationSelector: ".text-gen",
|
TextGenerationSelector: ".text-gen",
|
||||||
|
TextGenerationTimeoutMinutes: 10,
|
||||||
}
|
}
|
||||||
err = store.SaveSettings(settings)
|
err = store.SaveSettings(settings)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -171,4 +173,5 @@ func TestInMemoryStore_GetSettings(t *testing.T) {
|
|||||||
settings, err = store.GetSettings()
|
settings, err = store.GetSettings()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, ".image-edit", settings.ImageEditSelector)
|
assert.Equal(t, ".image-edit", settings.ImageEditSelector)
|
||||||
|
assert.Equal(t, 10, settings.TextGenerationTimeoutMinutes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Settings struct {
|
|||||||
ImageEditSelector string `json:"image_edit_selector,omitempty"`
|
ImageEditSelector string `json:"image_edit_selector,omitempty"`
|
||||||
ImageGenerationSelector string `json:"image_generation_selector,omitempty"`
|
ImageGenerationSelector string `json:"image_generation_selector,omitempty"`
|
||||||
TextGenerationSelector string `json:"text_generation_selector,omitempty"`
|
TextGenerationSelector string `json:"text_generation_selector,omitempty"`
|
||||||
|
TextGenerationTimeoutMinutes int `json:"text_generation_timeout_minutes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileStore implements the Store interface using a file-based storage
|
// FileStore implements the Store interface using a file-based storage
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ func TestFileStore_SaveSettings(t *testing.T) {
|
|||||||
ImageEditSelector: ".image-edit",
|
ImageEditSelector: ".image-edit",
|
||||||
ImageGenerationSelector: ".image-gen",
|
ImageGenerationSelector: ".image-gen",
|
||||||
TextGenerationSelector: ".text-gen",
|
TextGenerationSelector: ".text-gen",
|
||||||
|
TextGenerationTimeoutMinutes: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = store.SaveSettings(settings)
|
err = store.SaveSettings(settings)
|
||||||
@@ -239,6 +240,7 @@ func TestFileStore_GetSettings(t *testing.T) {
|
|||||||
ImageEditSelector: ".image-edit",
|
ImageEditSelector: ".image-edit",
|
||||||
ImageGenerationSelector: ".image-gen",
|
ImageGenerationSelector: ".image-gen",
|
||||||
TextGenerationSelector: ".text-gen",
|
TextGenerationSelector: ".text-gen",
|
||||||
|
TextGenerationTimeoutMinutes: 10,
|
||||||
}
|
}
|
||||||
err = store.SaveSettings(settings)
|
err = store.SaveSettings(settings)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -247,4 +249,5 @@ func TestFileStore_GetSettings(t *testing.T) {
|
|||||||
settings, err = store.GetSettings()
|
settings, err = store.GetSettings()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, ".image-edit", settings.ImageEditSelector)
|
assert.Equal(t, ".image-edit", settings.ImageEditSelector)
|
||||||
|
assert.Equal(t, 10, settings.TextGenerationTimeoutMinutes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,56 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
content="width=device-width, initial-scale=0.9, maximum-scale=0.9, user-scalable=no, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
|
<meta name="theme-color" content="#f8f7ff" />
|
||||||
<title>Aethera - AI Conversation & Image Generator</title>
|
<title>Aethera - AI Conversation & Image Generator</title>
|
||||||
<script type="module" src="./dist/main.js"></script>
|
<script type="module" src="./dist/main.js"></script>
|
||||||
<link rel="stylesheet" href="./dist/styles.css" />
|
<link rel="stylesheet" href="./dist/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-primary-50" x-data x-init="$store.navigation.init()">
|
<body class="bg-primary-50" x-data x-init="$store.navigation.init()">
|
||||||
<!-- Nav -->
|
<!-- Nav - Fixed and fully transparent so page content always sits behind
|
||||||
<div
|
it (including under the iOS dynamic island, even at scroll=0). The
|
||||||
class="isolate fixed z-50 w-full flex justify-between mt-4 px-4 md:px-6"
|
pill inside provides its own background. Pages clear the nav with
|
||||||
|
padding-top: var(--nav-h). -->
|
||||||
|
<header
|
||||||
|
class="fixed top-0 left-0 right-0 z-50 flex justify-between px-4 md:px-6 pb-3 pointer-events-none"
|
||||||
|
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||||
>
|
>
|
||||||
<div class="size-9"></div>
|
<div class="size-9 flex items-center justify-start pointer-events-none">
|
||||||
|
<button
|
||||||
|
x-show="$store.navigation.activeTab === 'chats' && !$store.chatSidebar.mobileOpen"
|
||||||
|
@click="$store.chatSidebar.toggleMobile()"
|
||||||
|
:aria-expanded="$store.chatSidebar.mobileOpen ? 'true' : 'false'"
|
||||||
|
aria-label="Toggle conversation list"
|
||||||
|
class="md:hidden p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors cursor-pointer pointer-events-auto"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
x-show="!$store.chatSidebar.mobileOpen"
|
||||||
|
xmlns="http://www.w3.org/2000/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="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
x-show="$store.chatSidebar.mobileOpen"
|
||||||
|
xmlns="http://www.w3.org/2000/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="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Nav -->
|
<!-- Main Nav -->
|
||||||
<nav class="inline-flex bg-primary-100 rounded-full shadow-sm">
|
<nav class="inline-flex bg-primary-100 rounded-full pointer-events-auto">
|
||||||
<a
|
<a
|
||||||
href="#/chats"
|
href="#/chats"
|
||||||
:class="[
|
:class="[
|
||||||
@@ -58,7 +93,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="$store.theme.cycleTheme()"
|
@click="$store.theme.cycleTheme()"
|
||||||
x-init="$store.theme.init()"
|
x-init="$store.theme.init()"
|
||||||
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
|
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors pointer-events-auto"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -107,9 +142,9 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area - No fixed height; the document scrolls. -->
|
||||||
<main id="page-content" class="h-dvh"></main>
|
<main id="page-content"></main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,20 +1,148 @@
|
|||||||
<div x-data="chatManager()">
|
<div x-data="chatManager()">
|
||||||
<!-- 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="$store.chatSidebar.collapsed ? 'md:grid-cols-[3.5rem_minmax(0,1fr)]' : 'md:grid-cols-[18rem_minmax(0,1fr)]'"
|
||||||
|
class="md:grid transition-[grid-template-columns] duration-300 ease-out"
|
||||||
|
>
|
||||||
|
<!-- Desktop Sidebar - Flat rail flush with the page. Extends the full
|
||||||
|
viewport height; the transparent nav floats above its top region.
|
||||||
|
Internal padding-top clears the nav. -->
|
||||||
|
<aside
|
||||||
|
class="hidden md:flex md:flex-col md:sticky top-0 border-r border-primary-200/60"
|
||||||
|
style="height: 100dvh;"
|
||||||
|
>
|
||||||
|
<!-- Expanded Rail -->
|
||||||
|
<template x-if="!$store.chatSidebar.collapsed">
|
||||||
|
<div
|
||||||
|
class="flex flex-col h-full"
|
||||||
|
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||||
|
>
|
||||||
|
<div class="px-2 pb-3 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="$store.chatSidebar.toggleCollapsed()"
|
||||||
|
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
|
||||||
|
title="Collapse sidebar"
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="selectChat(null)"
|
||||||
|
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
|
||||||
|
title="New conversation"
|
||||||
|
aria-label="New conversation"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||||
|
<div
|
||||||
|
x-show="chats.length === 0"
|
||||||
|
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
|
||||||
|
>
|
||||||
|
<p class="text-sm">No chats yet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="group in chatGroups" :key="group.label">
|
||||||
|
<div class="mt-2 first:mt-0">
|
||||||
|
<div
|
||||||
|
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
|
||||||
|
x-text="group.label"
|
||||||
|
></div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<template x-for="chat in group.chats" :key="chat.id">
|
||||||
|
<div
|
||||||
|
@click="selectChat(chat.id)"
|
||||||
|
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
|
||||||
|
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||||
|
:title="chat.title || chat.initial_message"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div
|
||||||
|
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
|
||||||
|
class="text-sm text-primary-900 truncate min-w-0 flex-1"
|
||||||
|
x-text="chat.title || chat.initial_message || 'New conversation'"
|
||||||
|
></div>
|
||||||
|
<button
|
||||||
|
@click.stop="deleteChat($event, chat.id)"
|
||||||
|
class="opacity-0 group-hover:opacity-100 focus:opacity-100 shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-opacity cursor-pointer"
|
||||||
|
title="Delete chat"
|
||||||
|
aria-label="Delete chat"
|
||||||
|
>
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
x-show="chat.title && chat.initial_message"
|
||||||
|
class="text-xs text-primary-500/80 truncate mt-0.5"
|
||||||
|
x-text="chat.initial_message"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Collapsed Icon Rail -->
|
||||||
|
<template x-if="$store.chatSidebar.collapsed">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center px-2 pb-3 gap-1"
|
||||||
|
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="$store.chatSidebar.toggleCollapsed()"
|
||||||
|
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer"
|
||||||
|
title="Expand sidebar"
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="selectChat(null)"
|
||||||
|
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
|
||||||
|
title="New conversation"
|
||||||
|
aria-label="New conversation"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Chat Column - min-h ensures the sticky input pins to the viewport bottom
|
||||||
|
even when there are few messages (the column always fills the visible area).
|
||||||
|
The reading column (max-w-3xl) is centered within whatever space the grid gives. -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col min-w-0 px-4 md:px-6"
|
||||||
|
style="min-height: 100dvh;"
|
||||||
|
>
|
||||||
|
<!-- Messages (oldest first, newest last) -->
|
||||||
|
<div
|
||||||
|
class="flex-1 flex flex-col gap-1 pb-4 w-full"
|
||||||
|
style="padding-top: var(--nav-h);"
|
||||||
>
|
>
|
||||||
<template x-for="message in currentChatMessages" :key="message.id">
|
<template x-for="message in currentChatMessages" :key="message.id">
|
||||||
|
<div :class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']">
|
||||||
<div
|
<div
|
||||||
:class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']"
|
:class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%] border shadow-sm',
|
||||||
>
|
|
||||||
<div
|
|
||||||
:class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%]',
|
|
||||||
message.role === 'user'
|
message.role === 'user'
|
||||||
? 'bg-primary-100 text-primary-900 rounded-br-none'
|
? 'bg-primary-100/75 border-primary-300/30 text-primary-900 rounded-br-none'
|
||||||
: 'bg-primary-200 text-primary-900 rounded-bl-none'
|
: 'bg-primary-200/65 border-primary-300/40 text-primary-900 rounded-bl-none'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<!-- User Images -->
|
|
||||||
<div
|
<div
|
||||||
x-show="message.role === 'user' && message.images && message.images.length > 0"
|
x-show="message.role === 'user' && message.images && message.images.length > 0"
|
||||||
class="flex gap-1 mb-2 flex-wrap"
|
class="flex gap-1 mb-2 flex-wrap"
|
||||||
@@ -24,112 +152,82 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Thinking Section -->
|
|
||||||
<div
|
<div
|
||||||
x-show="message.thinking"
|
x-show="message.thinking"
|
||||||
x-data="{ expanded: false }"
|
x-data="{ expanded: false }"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
@click="expanded = !expanded"
|
@click="expanded = !expanded"
|
||||||
|
@keydown.enter.prevent="expanded = !expanded"
|
||||||
|
@keydown.space.prevent="expanded = !expanded"
|
||||||
|
class="mb-3 rounded-md border-l-2 border-secondary-500/60 bg-secondary-100/35 px-3 py-2 ring-1 ring-secondary-400/20 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex items-center gap-2 text-xs font-medium text-secondary-700 transition-colors">
|
||||||
class="cursor-pointer rounded-lg overflow-hidden bg-primary-100 hover:bg-primary-50"
|
<span x-text="expanded ? '▾' : '▸'"></span>
|
||||||
>
|
<span>Reasoning</span>
|
||||||
<div
|
|
||||||
class="flex justify-center w-full px-3 py-2 text-xs text-primary-700 flex items-center gap-2 transition-colors"
|
|
||||||
>
|
|
||||||
<span x-text="expanded ? '▼' : '◀'"></span>
|
|
||||||
<span class="font-medium">Reasoning</span>
|
|
||||||
<span x-text="expanded ? '▼' : '▶'"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
x-show="expanded"
|
x-show="expanded"
|
||||||
class="prose p-4 max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
|
class="prose mt-2 max-w-none text-xs text-secondary-700 opacity-90 prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
|
||||||
x-html="renderMarkdown(message.thinking)"
|
x-html="renderMarkdown(message.thinking)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr x-show="message.thinking" class="my-2 border-primary-400/50" />
|
|
||||||
|
|
||||||
<!-- Loading Spinner (Streaming with no content yet) -->
|
|
||||||
<div
|
<div
|
||||||
x-show="message.role === 'assistant' && message.status === 'streaming' && !message.thinking && !message.content"
|
x-show="message.role === 'assistant' && message.status === 'streaming' && !message.thinking && !message.content"
|
||||||
class="flex items-center gap-2 py-1"
|
class="flex items-center gap-2 py-1"
|
||||||
>
|
>
|
||||||
<div
|
<div class="h-4 w-4 animate-spin rounded-full border-2 border-secondary-500 border-t-transparent"></div>
|
||||||
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
<span class="text-xs text-secondary-600">Thinking...</span>
|
||||||
></div>
|
|
||||||
<span class="text-xs text-primary-600">Thinking...</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div
|
<div
|
||||||
x-show="message.content || message.status !== 'streaming'"
|
x-show="message.content || message.status !== 'streaming'"
|
||||||
class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
|
class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
|
||||||
x-html="renderMarkdown(message.content)"
|
x-html="renderMarkdown(message.content)"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Message Metadata -->
|
|
||||||
<div class="flex items-center justify-between gap-2 mt-2">
|
<div class="flex items-center justify-between gap-2 mt-2">
|
||||||
<div
|
<div class="text-[10px] opacity-60" x-text="new Date(message.created_at).toLocaleTimeString()"></div>
|
||||||
class="text-[10px] opacity-60"
|
|
||||||
x-text="new Date(message.created_at).toLocaleTimeString()"
|
|
||||||
></div>
|
|
||||||
<div
|
<div
|
||||||
x-show="message.role === 'assistant' && ['stopped', 'error', 'failed'].includes(message.status)"
|
x-show="message.role === 'assistant' && ['stopped', 'error', 'failed'].includes(message.status)"
|
||||||
:class="message.status === 'stopped' ? 'bg-primary-300/50 text-primary-700' : 'bg-tertiary-100 text-tertiary-700'"
|
:class="message.status === 'stopped' ? 'bg-red-500/15 text-red-700 ring-red-500/20' : 'bg-red-600 text-white ring-red-700/20'"
|
||||||
class="px-2 py-0.5 rounded-full text-[10px] font-medium"
|
class="px-2 py-0.5 rounded-full text-[10px] font-medium ring-1"
|
||||||
x-text="message.status === 'stopped' ? 'Stopped' : 'Error'"
|
x-text="message.status === 'stopped' ? 'Stopped' : 'Error'"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Badges (Assistant) -->
|
|
||||||
<div
|
<div
|
||||||
x-show="message.role === 'assistant' && message.stats"
|
x-show="message.role === 'assistant' && message.stats"
|
||||||
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700"
|
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-600"
|
||||||
>
|
>
|
||||||
<!-- Cumulative Tokens with Hover Breakdown -->
|
|
||||||
<div
|
<div
|
||||||
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
|
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
|
||||||
class="group relative px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full cursor-help"
|
class="group relative px-2 py-0.5 text-[10px] bg-primary-200/40 rounded-full cursor-help"
|
||||||
>
|
>
|
||||||
<span
|
<span x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"></span>
|
||||||
x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"
|
<div class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2">
|
||||||
></span>
|
<div x-show="message.stats?.prompt_tokens" x-text="message.stats?.prompt_tokens"></div>
|
||||||
|
|
||||||
<!-- Tokens -->
|
|
||||||
<div
|
|
||||||
class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
x-show="message.stats?.prompt_tokens"
|
|
||||||
x-text="message.stats?.prompt_tokens"
|
|
||||||
></div>
|
|
||||||
<div x-show="message.stats?.prompt_tokens">prompt tokens</div>
|
<div x-show="message.stats?.prompt_tokens">prompt tokens</div>
|
||||||
|
<div x-show="message.stats?.generated_tokens" x-text="message.stats?.generated_tokens"></div>
|
||||||
<div
|
<div x-show="message.stats?.generated_tokens">generated tokens</div>
|
||||||
x-show="message.stats?.generated_tokens"
|
|
||||||
x-text="message.stats?.generated_tokens"
|
|
||||||
></div>
|
|
||||||
<div x-show="message.stats?.generated_tokens">
|
|
||||||
generated tokens
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
x-show="message.stats?.prompt_per_second"
|
x-show="message.stats?.prompt_per_second"
|
||||||
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
|
class="px-2 py-0.5 text-[10px] bg-primary-200/40 rounded-full"
|
||||||
x-text="`${message.stats?.prompt_per_second.toFixed(1)} ppt/s`"
|
x-text="`${message.stats?.prompt_per_second.toFixed(1)} ppt/s`"
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
x-show="message.stats?.generated_per_second"
|
x-show="message.stats?.generated_per_second"
|
||||||
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
|
class="px-2 py-0.5 text-[10px] bg-primary-200/40 rounded-full"
|
||||||
x-text="`${message.stats?.generated_per_second.toFixed(1)} tgt/s`"
|
x-text="`${message.stats?.generated_per_second.toFixed(1)} tgt/s`"
|
||||||
></span>
|
></span>
|
||||||
<span
|
<span
|
||||||
x-show="message.stats?.time_to_first_token"
|
x-show="message.stats?.time_to_first_token"
|
||||||
class="px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full"
|
class="px-2 py-0.5 text-[10px] bg-primary-200/40 rounded-full"
|
||||||
x-text="`${(message.stats?.time_to_first_token / 1000).toFixed(2)}s TTFT`"
|
x-text="`${(message.stats?.time_to_first_token / 1000).toFixed(2)}s TTFT`"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,102 +235,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Input and Model Selection -->
|
<!-- Sticky Input Bar -->
|
||||||
<div class="fixed bottom-4 w-full flex justify-center px-4 md:px-6">
|
|
||||||
<div class="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200"
|
class="sticky bottom-0 pt-2 bg-primary-50/95"
|
||||||
|
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom));"
|
||||||
>
|
>
|
||||||
<!-- Model Select -->
|
<form
|
||||||
<div class="relative">
|
@submit.prevent="sendMessage"
|
||||||
<select
|
class="flex flex-col gap-2 p-3 bg-primary-100/35 rounded-2xl border border-primary-300/40 shadow-sm"
|
||||||
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 -->
|
|
||||||
<input
|
<input
|
||||||
x-ref="fileInput"
|
x-ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -249,255 +260,242 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<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
|
<div class="flex items-center gap-2">
|
||||||
type="submit"
|
<div class="relative min-w-0 flex-shrink inline-flex items-center pl-7 pr-6 py-1 bg-primary-100/60 hover:bg-primary-200/70 border border-primary-300/50 rounded-full transition-colors">
|
||||||
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
|
<span
|
||||||
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
|
class="text-primary-900 text-xs font-medium truncate pointer-events-none"
|
||||||
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"
|
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 :value="model.id" x-text="model.name || model.id"></option>
|
||||||
></div>
|
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!loading">
|
</select>
|
||||||
<svg
|
<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">
|
||||||
class="h-4 w-4"
|
<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" />
|
||||||
fill="none"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
<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">
|
||||||
stroke="currentColor"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
>
|
</svg>
|
||||||
<path
|
</div>
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
<button
|
||||||
stroke-width="2"
|
type="button"
|
||||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
@click="thinkingEnabled = !thinkingEnabled"
|
||||||
/>
|
:class="thinkingEnabled
|
||||||
|
? 'bg-secondary-600 text-white border-secondary-600 hover:bg-secondary-700'
|
||||||
|
: 'bg-transparent text-secondary-500 border-secondary-300 hover:bg-secondary-200/70'"
|
||||||
|
class="relative h-7 w-7 rounded-full border transition-colors flex items-center justify-center flex-shrink-0 cursor-pointer"
|
||||||
|
: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>
|
</svg>
|
||||||
</template>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$refs.fileInput.click()"
|
||||||
|
class="h-7 w-7 bg-primary-100/70 text-primary-700 border border-primary-300/50 rounded-full transition-colors flex items-center justify-center flex-shrink-0 hover:bg-primary-200/70"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
x-show="loading"
|
x-show="loading"
|
||||||
type="button"
|
type="button"
|
||||||
@click="stopResponse()"
|
@click="stopResponse()"
|
||||||
:disabled="!activeStreamChatID"
|
:disabled="!activeStreamChatID"
|
||||||
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md hover:scale-105'"
|
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:bg-tertiary-700'"
|
||||||
class="self-stretch w-[44px] bg-tertiary-600 text-white rounded-xl transition-all flex items-center justify-center flex-shrink-0"
|
class="h-7 w-7 bg-tertiary-600 text-white rounded-full transition-colors flex items-center justify-center flex-shrink-0"
|
||||||
title="Stop response"
|
title="Stop response"
|
||||||
aria-label="Stop response"
|
aria-label="Stop response"
|
||||||
>
|
>
|
||||||
<svg
|
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
class="h-4 w-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 6h12v12H6z" />
|
<path d="M6 6h12v12H6z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Error Message -->
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
|
||||||
|
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary-600'"
|
||||||
|
class="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Conversation List Toggle -->
|
<!-- Mobile Sidebar Drawer (controlled from the top nav hamburger) -->
|
||||||
|
<div
|
||||||
|
x-show="$store.chatSidebar.mobileOpen"
|
||||||
|
x-cloak
|
||||||
|
x-transition.opacity
|
||||||
|
@click="$store.chatSidebar.mobileOpen = false"
|
||||||
|
class="md:hidden fixed inset-0 z-60 bg-black/40"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
x-show="$store.chatSidebar.mobileOpen"
|
||||||
|
x-cloak
|
||||||
|
x-transition:enter="transform transition-transform duration-300 ease-out"
|
||||||
|
x-transition:enter-start="-translate-x-full"
|
||||||
|
x-transition:enter-end="translate-x-0"
|
||||||
|
x-transition:leave="transform transition-transform duration-300 ease-in"
|
||||||
|
x-transition:leave-start="translate-x-0"
|
||||||
|
x-transition:leave-end="-translate-x-full"
|
||||||
|
class="md:hidden fixed top-0 left-0 bottom-0 z-60 w-[calc(100vw-3.5rem)] max-w-none bg-primary-50 flex flex-col"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="px-3 pb-3 flex items-center gap-2 border-b border-primary-200/60"
|
||||||
|
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
@click="chatListOpen = !chatListOpen"
|
@click="$store.chatSidebar.mobileOpen = false"
|
||||||
:aria-expanded="chatListOpen ? 'true' : 'false'"
|
class="size-9 flex items-center justify-center rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
|
||||||
aria-label="Toggle left navigation"
|
title="Collapse sidebar"
|
||||||
class="isolate cursor-pointer fixed z-50 flex justify-between top-4 left-4 md:left-6 p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
|
aria-label="Collapse sidebar"
|
||||||
>
|
>
|
||||||
<svg
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
x-show="!chatListOpen"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
xmlns="http://www.w3.org/2000/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="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
x-show="chatListOpen"
|
|
||||||
xmlns="http://www.w3.org/2000/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="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
<!-- Floating Conversation List -->
|
@click="selectChat(null); $store.chatSidebar.mobileOpen = false"
|
||||||
<div
|
class="size-9 flex items-center justify-center rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
|
||||||
x-show="chatListOpen"
|
title="New conversation"
|
||||||
x-transition:enter="transform transition-all duration-300 ease-out"
|
aria-label="New conversation"
|
||||||
x-transition:enter-start="-translate-x-full opacity-0"
|
|
||||||
x-transition:enter-end="translate-x-0 opacity-100"
|
|
||||||
x-transition:leave="transform transition-all duration-300 ease-in"
|
|
||||||
x-transition:leave-start="translate-x-0 opacity-100"
|
|
||||||
x-transition:leave-end="-translate-x-full opacity-0"
|
|
||||||
class="fixed top-16 left-0 right-0 mx-auto md:left-6 md:right-auto md:mx-0 bottom-4 w-86 bg-primary-100 rounded-xl shadow-lg z-20 overflow-hidden flex flex-col"
|
|
||||||
>
|
>
|
||||||
<div class="px-4 py-3 border-b border-primary-200 flex justify-center">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<h4 class="font-semibold text-primary-900">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
<span>Conversations</span>
|
</svg>
|
||||||
</h4>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conversation List-->
|
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||||
<div id="left-nav-desktop" class="flex-1 overflow-y-auto p-4">
|
|
||||||
<div
|
<div
|
||||||
x-show="chats.length === 0"
|
x-show="chats.length === 0"
|
||||||
class="h-full flex flex-col justify-center text-center py-8 text-primary-600"
|
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-10 w-10 mx-auto mb-2 text-primary-300"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm">No chats yet</p>
|
<p class="text-sm">No chats yet</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<template x-for="group in chatGroups" :key="group.label">
|
||||||
<template x-for="chat in chats" :key="chat.id">
|
<div class="mt-2 first:mt-0">
|
||||||
<div
|
<div
|
||||||
@click="selectChat(chat.id); chatListOpen = false;"
|
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
|
||||||
:class="[
|
x-text="group.label"
|
||||||
'p-3 rounded-lg cursor-pointer transition-all border-l-3',
|
></div>
|
||||||
selectedChatID === chat.id
|
<div class="space-y-0.5">
|
||||||
? 'bg-primary-200 border-l-primary-600'
|
<template x-for="chat in group.chats" :key="chat.id">
|
||||||
: 'hover:bg-primary-200 border-l-transparent'
|
|
||||||
]"
|
|
||||||
:title="chat.title"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<!-- Icon -->
|
|
||||||
<div class="mt-0.5 shrink-0">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
:class="[
|
|
||||||
'h-4 w-4',
|
|
||||||
selectedChatID === chat.id ? 'text-primary-600' : 'text-primary-400'
|
|
||||||
]"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="font-medium text-sm text-primary-900 truncate">
|
|
||||||
<span x-text="chat.title || 'New Conversation'"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 mt-1.5 text-xs text-primary-600"
|
@click="selectChat(chat.id); $store.chatSidebar.mobileOpen = false"
|
||||||
|
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
|
||||||
|
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||||
|
:title="chat.title || chat.initial_message"
|
||||||
>
|
>
|
||||||
<span
|
<div class="flex items-center justify-between gap-2">
|
||||||
x-show="chat.message_count > 0"
|
<div
|
||||||
class="shrink-0 bg-primary-300 text-primary-700 px-1.5 py-0.5 rounded text-[10px] font-medium"
|
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
|
||||||
x-text="chat.message_count"
|
class="text-sm text-primary-900 truncate min-w-0 flex-1"
|
||||||
></span>
|
x-text="chat.title || chat.initial_message || 'New conversation'"
|
||||||
<span class="truncate" x-text="chat.initial_message"></span>
|
></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click.stop="deleteChat($event, chat.id)"
|
@click.stop="deleteChat($event, chat.id)"
|
||||||
class="cursor-pointer shrink-0 p-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors"
|
class="shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors cursor-pointer"
|
||||||
title="Delete Chat"
|
title="Delete chat"
|
||||||
|
aria-label="Delete chat"
|
||||||
>
|
>
|
||||||
<svg
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
x-show="chat.title && chat.initial_message"
|
||||||
|
class="text-xs text-primary-500/80 truncate mt-0.5"
|
||||||
|
x-text="chat.initial_message"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Left Nav Footer -->
|
|
||||||
<div
|
|
||||||
x-show="$store.navigation.activeTab === 'chats'"
|
|
||||||
class="p-4 border-t border-primary-200 shrink-0"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
@click="selectChat(null)"
|
|
||||||
class="w-full px-4 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium flex cursor-pointer items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
New Conversation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
class="flex flex-col gap-4 pb-4 mx-auto px-4 md:px-6 max-w-6xl"
|
||||||
|
style="padding-top: var(--nav-h);"
|
||||||
x-data="imageGenerator()"
|
x-data="imageGenerator()"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -145,13 +146,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@click="clearMask"
|
@click="clearMask"
|
||||||
class="mt-2 px-3 py-1 cursor-pointer bg-primary-200 text-primary-700 rounded hover:bg-primary-600 hover:text-white text-center transition-colors"
|
class="mt-2 px-3 py-1 cursor-pointer bg-tertiary-100 text-tertiary-700 rounded hover:bg-tertiary-600 hover:text-white text-center transition-colors"
|
||||||
>
|
>
|
||||||
Clear Mask
|
Clear Mask
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@click="cancelEdit"
|
@click="cancelEdit"
|
||||||
class="mt-2 px-3 py-1 cursor-pointer bg-primary-200 text-primary-700 rounded hover:bg-primary-600 hover:text-white text-center transition-colors"
|
class="mt-2 px-3 py-1 cursor-pointer bg-tertiary-100 text-tertiary-700 rounded hover:bg-tertiary-600 hover:text-white text-center transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</span>
|
</span>
|
||||||
@@ -163,7 +164,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
x-bind:disabled="loading || !selectedModel"
|
x-bind:disabled="loading || !selectedModel"
|
||||||
:class="loading || !selectedModel ? 'cursor-not-allowed' : 'cursor-pointer'"
|
:class="loading || !selectedModel ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||||
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 gap-2 transition-colors"
|
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-primary-50 bg-secondary-600 hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary-500 disabled:opacity-50 gap-2 transition-colors"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
x-text="loading ? '' : editMode ? 'Edit Image' : 'Generate Image'"
|
x-text="loading ? '' : editMode ? 'Edit Image' : 'Generate Image'"
|
||||||
@@ -199,7 +200,7 @@
|
|||||||
>
|
>
|
||||||
<template x-for="(image, index) in generatedImages" :key="index">
|
<template x-for="(image, index) in generatedImages" :key="index">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-2 break-inside-avoid border border-primary-200 rounded-lg p-2 mb-2 h-full bg-primary-100 hover:border-primary-300 transition-colors shadow"
|
class="flex flex-col gap-2 break-inside-avoid border border-secondary-200 rounded-lg p-2 mb-2 h-full bg-secondary-50/60 hover:border-secondary-300 transition-colors shadow"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="deleteImage(image.name)"
|
@click="deleteImage(image.name)"
|
||||||
@@ -214,7 +215,7 @@
|
|||||||
class="rounded-lg shadow-sm max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
class="rounded-lg shadow-sm max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-xs text-primary-500 bg-primary-200 px-2 py-1 rounded flex justify-center"
|
class="text-xs text-secondary-700 bg-secondary-100 px-2 py-1 rounded flex justify-center"
|
||||||
x-text="image.date"
|
x-text="image.date"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<form
|
<form
|
||||||
x-data="settingsManager()"
|
x-data="settingsManager()"
|
||||||
@submit.prevent="saveSettings"
|
@submit.prevent="saveSettings"
|
||||||
class="p-0.5 w-full flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
class="p-0.5 w-full flex flex-col gap-4 pb-4 mx-auto px-4 md:px-6 max-w-6xl"
|
||||||
|
style="padding-top: var(--nav-h);"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-medium font-semibold text-primary-700"
|
<span class="text-sm font-medium font-semibold text-primary-700"
|
||||||
@@ -63,6 +64,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-sm font-medium font-semibold text-primary-700"
|
||||||
|
>Generation</span
|
||||||
|
>
|
||||||
|
<div class="flex flex-col md:flex-row pl-1 gap-4 justify-between">
|
||||||
|
<div class="w-full md:w-1/3">
|
||||||
|
<label
|
||||||
|
for="textGenerationTimeout"
|
||||||
|
class="text-sm font-medium text-primary-700"
|
||||||
|
>Chat Timeout</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="textGenerationTimeout"
|
||||||
|
name="textGenerationTimeout"
|
||||||
|
x-model.number="settings.text_generation_timeout_minutes"
|
||||||
|
class="mt-1 p-1 block w-full rounded-md border-primary-400 shadow focus:border-secondary-500 focus:ring-secondary-500 sm:text-sm text-primary-900"
|
||||||
|
>
|
||||||
|
<template x-for="minutes in timeoutOptions" x-bind:key="minutes">
|
||||||
|
<option x-bind:value="minutes" x-text="`${minutes}m`"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<p class="mt-2 text-xs text-primary-500">
|
||||||
|
Maximum time a chat response can stream before timing out
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
x-show="error"
|
x-show="error"
|
||||||
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4"
|
class="bg-tertiary-50 border border-tertiary-200 rounded-md p-4"
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
} from '../client';
|
} from '../client';
|
||||||
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
||||||
import { applyFilter } from '../utils';
|
import { applyFilter } from '../utils';
|
||||||
|
import { createAutoScroll, AutoScroll } from '../utils/autoScroll';
|
||||||
|
|
||||||
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,16 +39,20 @@ 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,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
activeStreamChatID: null as string | null,
|
activeStreamChatID: null as string | null,
|
||||||
|
|
||||||
|
_autoScroll: null as AutoScroll | null,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
this._autoScroll = createAutoScroll();
|
||||||
|
|
||||||
// Acquire Data
|
// Acquire Data
|
||||||
this._models = await getModels();
|
this._models = await getModels();
|
||||||
this.settings = await getSettings();
|
this.settings = await getSettings();
|
||||||
@@ -56,6 +62,7 @@ Alpine.data('chatManager', () => ({
|
|||||||
// Route Chat
|
// Route Chat
|
||||||
const chatID = window.location.hash.split('/')[2];
|
const chatID = window.location.hash.split('/')[2];
|
||||||
if (chatID) await this.selectChat(chatID);
|
if (chatID) await this.selectChat(chatID);
|
||||||
|
this._autoScroll.scrollToBottom();
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadChats() {
|
async loadChats() {
|
||||||
@@ -80,7 +87,6 @@ Alpine.data('chatManager', () => ({
|
|||||||
if (this.selectedChatID == chatId) {
|
if (this.selectedChatID == chatId) {
|
||||||
const newIndex = Math.min(chatIndex, this.chats.length - 1);
|
const newIndex = Math.min(chatIndex, this.chats.length - 1);
|
||||||
this.selectChat(this.chats[newIndex]?.id);
|
this.selectChat(this.chats[newIndex]?.id);
|
||||||
if (!this.selectedChatID) this.chatListOpen = false;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting conversation:', err);
|
console.error('Error deleting conversation:', err);
|
||||||
@@ -113,6 +119,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) {
|
||||||
@@ -141,11 +148,12 @@ Alpine.data('chatManager', () => ({
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
currentChat.message_count += 1;
|
currentChat.message_count += 1;
|
||||||
|
this._autoScroll?.scrollToBottom('smooth');
|
||||||
|
|
||||||
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);
|
||||||
@@ -189,6 +197,8 @@ Alpine.data('chatManager', () => ({
|
|||||||
if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message);
|
if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message);
|
||||||
if (chunk.assistant_message)
|
if (chunk.assistant_message)
|
||||||
this.upsertMessage(currentChat, chunk.assistant_message);
|
this.upsertMessage(currentChat, chunk.assistant_message);
|
||||||
|
|
||||||
|
this._autoScroll?.maybeScrollToBottom();
|
||||||
},
|
},
|
||||||
|
|
||||||
upsertMessage(chat: Chat, message: Message) {
|
upsertMessage(chat: Chat, message: Message) {
|
||||||
@@ -219,8 +229,10 @@ Alpine.data('chatManager', () => ({
|
|||||||
|
|
||||||
// Load Messages
|
// Load Messages
|
||||||
this.selectedChatID = chatID;
|
this.selectedChatID = chatID;
|
||||||
if (!this.selectedChatID) this.chatListOpen = false;
|
if (this.selectedChatID) {
|
||||||
else this.loadChatMessages();
|
await this.loadChatMessages();
|
||||||
|
this._autoScroll?.scrollToBottom();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadChatMessages() {
|
async loadChatMessages() {
|
||||||
@@ -282,7 +294,11 @@ Alpine.data('chatManager', () => ({
|
|||||||
const currentChat =
|
const currentChat =
|
||||||
this.chats.find((c) => c.id === this.selectedChatID) ?? null;
|
this.chats.find((c) => c.id === this.selectedChatID) ?? null;
|
||||||
if (!currentChat) return [];
|
if (!currentChat) return [];
|
||||||
return [...currentChat.messages].reverse();
|
return currentChat.messages;
|
||||||
|
},
|
||||||
|
|
||||||
|
get chatGroups(): { label: string; chats: Chat[] }[] {
|
||||||
|
return groupChatsByDay(this.chats);
|
||||||
},
|
},
|
||||||
|
|
||||||
renderMarkdown(content: string) {
|
renderMarkdown(content: string) {
|
||||||
@@ -290,6 +306,47 @@ Alpine.data('chatManager', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function startOfDay(d: Date): number {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupChatsByDay(
|
||||||
|
chats: Chat[],
|
||||||
|
): { label: string; chats: Chat[] }[] {
|
||||||
|
const now = new Date();
|
||||||
|
const today = startOfDay(now);
|
||||||
|
const yesterday = today - 86_400_000;
|
||||||
|
|
||||||
|
const groups: { key: number; label: string; chats: Chat[] }[] = [];
|
||||||
|
let current: { key: number; label: string; chats: Chat[] } | null = null;
|
||||||
|
|
||||||
|
for (const chat of chats) {
|
||||||
|
const created = new Date(chat.created_at);
|
||||||
|
const day = startOfDay(created);
|
||||||
|
|
||||||
|
if (!current || current.key !== day) {
|
||||||
|
let label: string;
|
||||||
|
if (day === today) label = 'Today';
|
||||||
|
else if (day === yesterday) label = 'Yesterday';
|
||||||
|
else {
|
||||||
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
};
|
||||||
|
if (created.getFullYear() !== now.getFullYear())
|
||||||
|
opts.year = 'numeric';
|
||||||
|
label = created.toLocaleDateString(undefined, opts);
|
||||||
|
}
|
||||||
|
current = { key: day, label, chats: [] };
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
current.chats.push(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
function parseError(err: unknown): string {
|
function parseError(err: unknown): string {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
|||||||
24
frontend/src/components/chatSidebarStore.ts
Normal file
24
frontend/src/components/chatSidebarStore.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Alpine from 'alpinejs';
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = 'aethera-chat-sidebar-collapsed';
|
||||||
|
|
||||||
|
interface ChatSidebarStore {
|
||||||
|
mobileOpen: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
toggleMobile(): void;
|
||||||
|
toggleCollapsed(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store: ChatSidebarStore = {
|
||||||
|
mobileOpen: false,
|
||||||
|
collapsed: localStorage.getItem(COLLAPSED_KEY) === 'true',
|
||||||
|
toggleMobile() {
|
||||||
|
this.mobileOpen = !this.mobileOpen;
|
||||||
|
},
|
||||||
|
toggleCollapsed() {
|
||||||
|
this.collapsed = !this.collapsed;
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, String(this.collapsed));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Alpine.store('chatSidebar', store);
|
||||||
@@ -4,12 +4,14 @@ import { Settings } from '../types';
|
|||||||
|
|
||||||
Alpine.data('settingsManager', () => ({
|
Alpine.data('settingsManager', () => ({
|
||||||
settings: {} as Settings,
|
settings: {} as Settings,
|
||||||
|
timeoutOptions: [1, 5, 10, 15, 30],
|
||||||
loading: false,
|
loading: false,
|
||||||
saved: false,
|
saved: false,
|
||||||
error: '',
|
error: '',
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.settings = await getSettings();
|
this.settings = await getSettings();
|
||||||
|
this.settings.text_generation_timeout_minutes ||= 5;
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import './components/imageManager';
|
|||||||
import './components/settingsManager';
|
import './components/settingsManager';
|
||||||
import './components/themeManager';
|
import './components/themeManager';
|
||||||
import './components/navigationManager';
|
import './components/navigationManager';
|
||||||
|
import './components/chatSidebarStore';
|
||||||
|
|
||||||
// Start Alpine
|
// Start Alpine
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface Settings {
|
|||||||
image_edit_selector?: string;
|
image_edit_selector?: string;
|
||||||
image_generation_selector?: string;
|
image_generation_selector?: string;
|
||||||
text_generation_selector?: string;
|
text_generation_selector?: string;
|
||||||
|
text_generation_timeout_minutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageRecord {
|
export interface ImageRecord {
|
||||||
@@ -70,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 {
|
||||||
|
|||||||
43
frontend/src/utils/autoScroll.ts
Normal file
43
frontend/src/utils/autoScroll.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Pin-to-bottom controller for the document scroll.
|
||||||
|
//
|
||||||
|
// Tracks whether the user is "near" the bottom of the page. While pinned, new
|
||||||
|
// content (e.g. streaming tokens) scrolls the viewport to keep up. Once the
|
||||||
|
// user scrolls up, pinning releases and updates stop forcing scroll until the
|
||||||
|
// user returns to the bottom.
|
||||||
|
|
||||||
|
const PIN_THRESHOLD_PX = 80;
|
||||||
|
|
||||||
|
export interface AutoScroll {
|
||||||
|
isPinned(): boolean;
|
||||||
|
scrollToBottom(behavior?: ScrollBehavior): void;
|
||||||
|
maybeScrollToBottom(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutoScroll(): AutoScroll {
|
||||||
|
let pinned = true;
|
||||||
|
|
||||||
|
const scrollEl = () => document.scrollingElement || document.documentElement;
|
||||||
|
const distanceFromBottom = () => {
|
||||||
|
const el = scrollEl();
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
pinned = distanceFromBottom() < PIN_THRESHOLD_PX;
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPinned: () => pinned,
|
||||||
|
scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
||||||
|
window.scrollTo({ top: scrollEl().scrollHeight, behavior });
|
||||||
|
pinned = true;
|
||||||
|
},
|
||||||
|
maybeScrollToBottom() {
|
||||||
|
if (!pinned) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: scrollEl().scrollHeight });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Top nav clearance - pages use this to pad past the fixed nav. */
|
||||||
|
--nav-h: calc(max(1rem, env(safe-area-inset-top)) + 3rem);
|
||||||
|
background: var(--color-primary-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: var(--color-primary-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[x-cloak] {
|
[x-cloak] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -58,36 +77,36 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
.dark {
|
.dark {
|
||||||
/* Dark mode - dark backgrounds (50-300), light text (700-900) */
|
/* Dark mode - background and surface steps are intentionally lifted for a softer dark theme. */
|
||||||
--color-primary-50: oklch(15% 0.08 290);
|
--color-primary-50: oklch(25% 0.08 290);
|
||||||
--color-primary-100: oklch(18% 0.1 290);
|
--color-primary-100: oklch(31% 0.1 290);
|
||||||
--color-primary-200: oklch(22% 0.12 290);
|
--color-primary-200: oklch(36% 0.12 290);
|
||||||
--color-primary-300: oklch(28% 0.15 290);
|
--color-primary-300: oklch(48% 0.15 290);
|
||||||
--color-primary-400: oklch(36% 0.18 290);
|
--color-primary-400: oklch(52% 0.18 290);
|
||||||
--color-primary-500: oklch(45% 0.2 290);
|
--color-primary-500: oklch(58% 0.2 290);
|
||||||
--color-primary-600: oklch(55% 0.18 290);
|
--color-primary-600: oklch(62% 0.18 290);
|
||||||
--color-primary-700: oklch(65% 0.15 290);
|
--color-primary-700: oklch(65% 0.15 290);
|
||||||
--color-primary-800: oklch(75% 0.12 290);
|
--color-primary-800: oklch(75% 0.12 290);
|
||||||
--color-primary-900: oklch(85% 0.08 290);
|
--color-primary-900: oklch(85% 0.08 290);
|
||||||
|
|
||||||
--color-secondary-50: oklch(15% 0.05 180);
|
--color-secondary-50: oklch(25% 0.05 180);
|
||||||
--color-secondary-100: oklch(18% 0.07 180);
|
--color-secondary-100: oklch(31% 0.07 180);
|
||||||
--color-secondary-200: oklch(22% 0.09 180);
|
--color-secondary-200: oklch(36% 0.09 180);
|
||||||
--color-secondary-300: oklch(28% 0.11 180);
|
--color-secondary-300: oklch(48% 0.11 180);
|
||||||
--color-secondary-400: oklch(36% 0.13 180);
|
--color-secondary-400: oklch(52% 0.13 180);
|
||||||
--color-secondary-500: oklch(45% 0.15 180);
|
--color-secondary-500: oklch(58% 0.15 180);
|
||||||
--color-secondary-600: oklch(55% 0.14 180);
|
--color-secondary-600: oklch(62% 0.14 180);
|
||||||
--color-secondary-700: oklch(65% 0.12 180);
|
--color-secondary-700: oklch(65% 0.12 180);
|
||||||
--color-secondary-800: oklch(75% 0.09 180);
|
--color-secondary-800: oklch(75% 0.09 180);
|
||||||
--color-secondary-900: oklch(85% 0.06 180);
|
--color-secondary-900: oklch(85% 0.06 180);
|
||||||
|
|
||||||
--color-tertiary-50: oklch(15% 0.008 60);
|
--color-tertiary-50: oklch(25% 0.008 60);
|
||||||
--color-tertiary-100: oklch(18% 0.01 60);
|
--color-tertiary-100: oklch(31% 0.01 60);
|
||||||
--color-tertiary-200: oklch(22% 0.015 60);
|
--color-tertiary-200: oklch(36% 0.015 60);
|
||||||
--color-tertiary-300: oklch(28% 0.02 60);
|
--color-tertiary-300: oklch(48% 0.02 60);
|
||||||
--color-tertiary-400: oklch(36% 0.025 60);
|
--color-tertiary-400: oklch(52% 0.025 60);
|
||||||
--color-tertiary-500: oklch(45% 0.03 60);
|
--color-tertiary-500: oklch(58% 0.03 60);
|
||||||
--color-tertiary-600: oklch(55% 0.025 60);
|
--color-tertiary-600: oklch(62% 0.025 60);
|
||||||
--color-tertiary-700: oklch(65% 0.02 60);
|
--color-tertiary-700: oklch(65% 0.02 60);
|
||||||
--color-tertiary-800: oklch(75% 0.015 60);
|
--color-tertiary-800: oklch(75% 0.015 60);
|
||||||
--color-tertiary-900: oklch(85% 0.01 60);
|
--color-tertiary-900: oklch(85% 0.01 60);
|
||||||
|
|||||||
Reference in New Issue
Block a user