diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css index 49ea558..5e799b2 100644 --- a/app/frontend/src/App.css +++ b/app/frontend/src/App.css @@ -1118,6 +1118,135 @@ body { padding: 8px; } +/* Custom Chat Model Dropdown Selection */ +.chat-model-select-trigger { + height: 34px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-radius: var(--md-shape-corner-medium); + background: var(--md-sys-color-surface-variant); + border: 1px solid var(--border-color); + color: var(--md-sys-color-on-surface); + padding: 0 12px; + font-family: Outfit, sans-serif; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + outline: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.chat-model-select-trigger:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--md-sys-color-primary) 40%, var(--border-color)); + background: color-mix(in srgb, var(--md-sys-color-surface-variant) 95%, var(--md-sys-color-primary)); +} + +.chat-model-select-trigger.active { + border-color: var(--md-sys-color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--md-sys-color-primary) 15%, transparent); + background: color-mix(in srgb, var(--md-sys-color-surface-variant) 95%, var(--md-sys-color-primary)); +} + +.chat-model-select-trigger:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.chat-model-select-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + text-align: left; +} + +.chat-model-select-arrow { + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); + color: var(--md-sys-color-outline); + flex-shrink: 0; +} + +.chat-model-select-arrow.open { + transform: rotate(180deg); + color: var(--md-sys-color-primary); +} + +.chat-model-dropdown-menu { + position: absolute; + top: 40px; + left: 0; + width: 100%; + min-width: 280px; + max-height: 280px; + overflow-y: auto; + background: color-mix(in srgb, var(--md-sys-color-surface) 95%, black); + backdrop-filter: blur(16px); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25); + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + z-index: 9999; + animation: theme-dropdown-fade 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.chat-model-dropdown-item { + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--md-sys-color-on-surface); + font-family: Outfit, sans-serif; + font-size: 0.85rem; + font-weight: 500; + text-align: left; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.chat-model-dropdown-item:hover { + background: color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent); + color: var(--md-sys-color-primary); +} + +.chat-model-dropdown-item.selected { + background: color-mix(in srgb, var(--md-sys-color-primary) 8%, var(--md-sys-color-surface-variant)); + font-weight: 600; +} + +.chat-model-dropdown-item.active { + border-left: 3px solid var(--md-sys-color-primary); + padding-left: 9px; +} + +.chat-model-name-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.chat-model-active-badge { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-primary); + padding: 2px 6px; + border-radius: 4px; + letter-spacing: 0.05em; +} + @keyframes rotateThinking { 0% { transform: rotate(0deg); diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index 2e4aa58..0b44307 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -58,6 +58,7 @@ function App() { const [activeModel, setActiveModel] = useState(null); const [serverRunning, setServerRunning] = useState(false); const [isStoppingServer, setIsStoppingServer] = useState(false); + const [isLlmLoaded, setIsLlmLoaded] = useState(false); const [backendOptions, setBackendOptions] = useState({ options: [{ id: "cpu", label: "CPU", available: true }], cudaAvailable: false, @@ -673,6 +674,7 @@ function App() { telemetry={telemetry} serverRunning={serverRunning} activeModel={activeModel} + isLlmLoaded={isLlmLoaded} onStopServer={handleStopServer} isStoppingServer={isStoppingServer} theme={theme} @@ -737,6 +739,7 @@ function App() { showHistory={showHistory} setShowHistory={setShowHistory} saveConversationState={saveConversationState} + setIsLlmLoaded={setIsLlmLoaded} /> diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx index c7b2469..f3bebcf 100644 --- a/app/frontend/src/components/TextChat.jsx +++ b/app/frontend/src/components/TextChat.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown, Globe2 } from "lucide-react"; +import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown, Globe2, Plus } from "lucide-react"; import { getDownloadProgress, getLlmStatus, @@ -10,11 +10,8 @@ import { } from "../services/api"; const processMessageContent = (rawText, apiReasoning = "", enableThinking = true) => { - let content = rawText; - let reasoning = apiReasoning || ""; - if (typeof rawText !== "string") { - return { content, reasoning }; + return { content: rawText, reasoning: apiReasoning || "" }; } const cleanReasoningControlTags = (value) => String(value || "") @@ -44,56 +41,38 @@ const processMessageContent = (rawText, apiReasoning = "", enableThinking = true } } - if (startIdx !== -1) { - let endIdx = -1; - let matchedEndTag = ""; - const searchArea = rawText.substring(startIdx + matchedStartTag.length); + if (startIdx === -1) { + return { content: cleanReasoningControlTags(rawText), reasoning: "" }; + } - for (const tag of endTags) { - const idx = searchArea.indexOf(tag); - if (idx !== -1 && (endIdx === -1 || idx < endIdx)) { - endIdx = idx; - matchedEndTag = tag; - } - } - - if (endIdx !== -1) { - const actualEndIdxInRaw = startIdx + matchedStartTag.length + endIdx; - const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length, actualEndIdxInRaw).trim(); - - reasoning = (reasoning + "\n" + extractedReasoning).trim(); - - const afterEndText = rawText.substring(actualEndIdxInRaw + matchedEndTag.length); - content = (rawText.substring(0, startIdx) + afterEndText).trim(); - - return processMessageContent(content, reasoning, enableThinking); - } else { - const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length).trim(); - reasoning = (reasoning + "\n" + extractedReasoning).trim(); - content = rawText.substring(0, startIdx).trim(); + let endIdx = -1; + for (const tag of endTags) { + const idx = rawText.indexOf(tag, startIdx + matchedStartTag.length); + if (idx !== -1 && (endIdx === -1 || idx < endIdx)) { + endIdx = idx; } } - return { content, reasoning }; + if (endIdx === -1) { + const rawReasoning = rawText.substring(startIdx + matchedStartTag.length); + return { content: "", reasoning: cleanReasoningControlTags(rawReasoning) }; + } + + const rawReasoning = rawText.substring(startIdx + matchedStartTag.length, endIdx); + const rawContent = rawText.substring(endIdx); + + return { + content: cleanReasoningControlTags(rawContent), + reasoning: cleanReasoningControlTags(rawReasoning) + }; }; -const messageContainsImage = (message) => ( - Array.isArray(message?.content) && - message.content.some((item) => item?.type === "image_url" && item?.image_url?.url) -); +function ChatThinkingSection({ reasoning, timeElapsed, isComplete }) { + const [isExpanded, setIsExpanded] = useState(true); -function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) { - const [isExpanded, setIsExpanded] = useState(false); - - useEffect(() => { - if (isComplete) { - setIsExpanded(false); - } - }, [isComplete]); - - if (!reasoning) return null; - - const formattedTime = thinkingDuration > 0 ? ` (${thinkingDuration.toFixed(1)}s)` : ""; + const formattedTime = timeElapsed > 0 + ? ` (${timeElapsed.toFixed(timeElapsed < 10 ? 1 : 0)}s)` + : ""; return (