From 0a181873e6c458f94e7cea0ff7f24586afcb0fe4 Mon Sep 17 00:00:00 2001 From: techjarves Date: Mon, 15 Jun 2026 15:53:09 +0530 Subject: [PATCH] feat: initialize frontend application scaffold with core layout, chat components, and API service --- app/frontend/src/App.css | 272 ++++++++++++++++ app/frontend/src/App.jsx | 9 +- app/frontend/src/components/ModelManager.jsx | 1 - app/frontend/src/components/Settings.jsx | 24 ++ app/frontend/src/components/TextChat.jsx | 307 +++++++++++++++---- app/frontend/src/services/api.js | 13 +- scripts/serve.cjs | 90 +++++- 7 files changed, 633 insertions(+), 83 deletions(-) diff --git a/app/frontend/src/App.css b/app/frontend/src/App.css index ab7dcd1..26b3263 100644 --- a/app/frontend/src/App.css +++ b/app/frontend/src/App.css @@ -2004,6 +2004,201 @@ html[data-theme="dark"] .m3-segment-item:hover:not(.active):not(.disabled) { color: var(--md-sys-color-on-surface-variant); } +.history-item-delete { + background: transparent; + border: none; + color: var(--md-sys-color-outline); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: transform var(--md-transition-duration) var(--md-transition-easing); +} + +.gallery-item:hover .gallery-thumb { + transform: scale(1.05); +} + +.gallery-select-badge { + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + border-radius: 50%; + display: grid; + place-items: center; + color: var(--md-sys-color-on-primary); + background: var(--md-sys-color-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); +} + +.app-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(8, 12, 24, 0.64); + backdrop-filter: blur(4px) saturate(0.86); +} + +.app-dialog { + width: min(460px, 100%); + background: color-mix(in srgb, var(--md-sys-color-surface) 96%, white 4%); + color: var(--md-sys-color-on-surface); + border: 1px solid color-mix(in srgb, var(--md-sys-color-outline) 55%, transparent); + border-radius: var(--md-shape-corner-large); + box-shadow: + 0 22px 60px rgba(0, 0, 0, 0.42), + 0 0 0 1px rgba(255, 255, 255, 0.08) inset; + padding: 24px; +} + +.app-dialog-title { + font-size: 1.15rem; + font-weight: 800; + margin: 0 0 10px 0; +} + +.app-dialog-message { + font-size: 0.92rem; + line-height: 1.45; + color: var(--md-sys-color-on-surface-variant); + margin: 0; + white-space: pre-wrap; +} + +.app-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 22px; + flex-wrap: wrap; +} + +.gallery-empty { + text-align: center; + padding: 36px; + color: var(--md-sys-color-on-surface-variant); + font-size: 0.85rem; + opacity: 0.8; +} + +/* Animations */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(1.06); } +} + +/* Sidebar specs classes */ +.sidebar-specs-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + font-weight: 600; + color: var(--md-sys-color-on-background); +} + +.sidebar-specs-item { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + opacity: 0.85; + margin-bottom: 2px; + color: var(--md-sys-color-on-background); +} + +.sidebar-specs-os { + color: var(--md-sys-color-primary); + font-weight: 600; + margin-top: 4px; +} + +/* Chat History Sidebar styling */ +.chat-history-sidebar { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--md-sys-color-surface-variant); + opacity: 0.95; + border: 1px solid var(--border-color); + border-radius: var(--md-shape-corner-large); + padding: 16px; + height: 100%; + overflow: hidden; + box-shadow: var(--glass-shadow); + transition: all 0.3s ease; +} + +.history-items-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; + overflow-y: auto; + flex: 1; +} + +.history-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: var(--md-shape-corner-medium); + cursor: pointer; + background: transparent; + transition: background-color 0.2s ease, border-color 0.2s ease; + border: 1px solid transparent; +} + +.history-item:hover { + background: rgba(120, 120, 120, 0.1); +} + +.history-item.active { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + border-color: var(--md-sys-color-outline-variant); +} + +.history-item-info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.history-item-title { + font-size: 0.85rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--md-sys-color-on-surface); +} + +.history-item-meta { + font-size: 0.68rem; + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; + color: var(--md-sys-color-on-surface-variant); +} + .history-item-delete { background: transparent; border: none; @@ -2023,3 +2218,80 @@ html[data-theme="dark"] .m3-segment-item:hover:not(.active):not(.disabled) { color: var(--md-sys-color-error); background: rgba(239, 68, 68, 0.1); } + +/* Chat Thinking Block (Reasoning Process) */ +.chat-thinking-container { + margin-bottom: 8px; + border-radius: 8px; + border: 1px dashed rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.03); + overflow: hidden; + max-width: 100%; +} + +.chat-thinking-header { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + border: none; + color: var(--md-sys-color-on-surface-variant); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: background 0.2s; + outline: none; +} + +.chat-thinking-header:hover { + background: rgba(255, 255, 255, 0.08); +} + +.chat-thinking-title { + display: flex; + align-items: center; + gap: 6px; + opacity: 0.8; +} + +.chat-thinking-content { + padding: 10px 12px; + font-size: 0.8rem; + line-height: 1.5; + color: var(--md-sys-color-on-surface-variant); + opacity: 0.75; + border-top: 1px dashed rgba(255, 255, 255, 0.1); + white-space: pre-wrap; + font-family: var(--font-family-monospace, monospace); + background: rgba(0, 0, 0, 0.1); + max-height: 200px; + overflow-y: auto; +} + +/* DeepThink button animations and interactions */ +.chat-composer-deepthink-btn:hover { + background: rgba(99, 102, 241, 0.08) !important; + color: var(--md-sys-color-primary) !important; + border-color: color-mix(in srgb, var(--md-sys-color-primary) 50%, transparent) !important; +} + +.chat-composer-deepthink-btn.active:hover { + background: rgba(99, 102, 241, 0.22) !important; +} + +@keyframes spin-slow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.rotate-anim { + animation: spin-slow 8s linear infinite; +} + diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index 36775bd..ad5a78e 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -118,14 +118,19 @@ function App() { const saved = localStorage.getItem("textSettings"); if (saved) { try { - return JSON.parse(saved); + const parsed = JSON.parse(saved); + return { + enableThinking: true, + ...parsed + }; } catch (_) {} } return { contextSize: 0, temperature: 0.7, systemPrompt: "You are a helpful local AI assistant.", - threads: 4 + threads: 4, + enableThinking: true }; }); diff --git a/app/frontend/src/components/ModelManager.jsx b/app/frontend/src/components/ModelManager.jsx index 3eadc55..5ef227b 100644 --- a/app/frontend/src/components/ModelManager.jsx +++ b/app/frontend/src/components/ModelManager.jsx @@ -25,7 +25,6 @@ import { } from "../services/api"; const MODEL_FILTERS = [ - { id: "potato", label: "Potato PC" }, { id: "vision", label: "Vision" }, { id: "uncensored", label: "Uncensored" }, ]; diff --git a/app/frontend/src/components/Settings.jsx b/app/frontend/src/components/Settings.jsx index 92108f8..090ca83 100644 --- a/app/frontend/src/components/Settings.jsx +++ b/app/frontend/src/components/Settings.jsx @@ -523,6 +523,30 @@ function Settings({ + {/* Show Thinking Process toggle */} +
+ +
+ {/* Temperature slider */}
diff --git a/app/frontend/src/components/TextChat.jsx b/app/frontend/src/components/TextChat.jsx index 4e6fee1..5ec8349 100644 --- a/app/frontend/src/components/TextChat.jsx +++ b/app/frontend/src/components/TextChat.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X } from "lucide-react"; +import { Bot, LoaderCircle, Send, Trash2, Square, History, Paperclip, X, ChevronDown } from "lucide-react"; import { getDownloadProgress, getLlmStatus, @@ -9,6 +9,99 @@ import { stopLlm, } from "../services/api"; +const processMessageContent = (rawText, apiReasoning = "") => { + let content = rawText; + let reasoning = apiReasoning || ""; + + if (typeof rawText !== "string") { + return { content, reasoning }; + } + + const startTags = ["<|channel|>thought", "<|think|>", "<|thought|>", "", ""]; + const endTags = ["<|channel|>model", "<|turn>model", "<|im_start|>model", "", "", "", ""]; + + let startIdx = -1; + let matchedStartTag = ""; + + for (const tag of startTags) { + const idx = rawText.indexOf(tag); + if (idx !== -1 && (startIdx === -1 || idx < startIdx)) { + startIdx = idx; + matchedStartTag = tag; + } + } + + if (startIdx !== -1) { + let endIdx = -1; + let matchedEndTag = ""; + const searchArea = rawText.substring(startIdx + matchedStartTag.length); + + 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; + + return processMessageContent(content, reasoning); + } else { + const extractedReasoning = rawText.substring(startIdx + matchedStartTag.length).trim(); + reasoning = (reasoning + "\n" + extractedReasoning).trim(); + content = rawText.substring(0, startIdx); + } + } + + return { content, reasoning }; +}; + +function ThinkingBlock({ reasoning, isComplete, thinkingDuration }) { + const [isExpanded, setIsExpanded] = useState(!isComplete); + + useEffect(() => { + if (isComplete) { + setIsExpanded(false); + } + }, [isComplete]); + + if (!reasoning) return null; + + const formattedTime = thinkingDuration > 0 ? ` (${thinkingDuration.toFixed(1)}s)` : ""; + + return ( +
+ + {isExpanded && ( +
+ {reasoning} +
+ )} +
+ ); +} + function TextChat({ specs, showAlert, @@ -245,6 +338,7 @@ function TextChat({ threads: textSettings?.threads || specs?.cpu_cores_physical || 4, contextSize: textSettings?.contextSize || 4096, gpuLayers: -1, + enableThinking: textSettings?.enableThinking !== false, }); setStatus({ ...status, ...result, ready: true, running: true, settings: result.settings }); setMessages([]); @@ -339,14 +433,18 @@ function TextChat({ ]; let assistantText = ""; + let assistantReasoning = ""; let streamedTokens = 0; let firstTokenAt = null; + let thinkingStartedAt = null; + let thinkingEndedAt = null; + let thinkingDuration = 0; const response = await streamChatWithLlm(requestMessages, { temperature: textSettings?.temperature || 0.7, maxTokens: 768, signal: controller.signal, - }, (_token, fullText) => { + }, (_token, fullText, _reasoningToken, fullReasoning) => { const now = performance.now(); if (streamedTokens === 0) { firstTokenAt = now; @@ -355,13 +453,31 @@ function TextChat({ const generationSeconds = firstTokenAt ? Math.max(0.05, (now - firstTokenAt) / 1000) : Math.max(0.05, (now - requestStartedAt) / 1000); - assistantText = fullText; + + const processed = processMessageContent(fullText, fullReasoning); + assistantText = processed.content; + assistantReasoning = processed.reasoning; + + // Calculate thinking duration + if (processed.reasoning && !thinkingStartedAt) { + thinkingStartedAt = now; + } + if (processed.content && thinkingStartedAt && !thinkingEndedAt) { + thinkingEndedAt = now; + thinkingDuration = (thinkingEndedAt - thinkingStartedAt) / 1000; + } + const currentThinkingDuration = thinkingEndedAt + ? thinkingDuration + : (thinkingStartedAt ? (now - thinkingStartedAt) / 1000 : 0); + setMessages((prev) => { const updated = [...prev]; if (updated.length > 0 && updated[updated.length - 1].role === "assistant") { updated[updated.length - 1] = { ...updated[updated.length - 1], - content: fullText, + content: processed.content, + reasoning: processed.reasoning, + thinkingDuration: currentThinkingDuration, generationStats: { status: "streaming", tokens: streamedTokens, @@ -375,6 +491,12 @@ function TextChat({ }); const completedAt = performance.now(); + let finalThinkingDuration = thinkingDuration; + if (thinkingStartedAt && !thinkingEndedAt) { + thinkingEndedAt = completedAt; + finalThinkingDuration = (thinkingEndedAt - thinkingStartedAt) / 1000; + } + const exactTokens = Number(response.timings?.predicted_n) || streamedTokens; const backendTotalMs = Number(response.timings?.prompt_ms || 0) + Number(response.timings?.predicted_ms || 0); const exactSeconds = backendTotalMs > 0 @@ -388,9 +510,13 @@ function TextChat({ tokensPerSecond: exactTokensPerSecond, seconds: exactSeconds, }; + + const processed = processMessageContent(assistantText, response.reasoningContent || assistantReasoning); const finalMessages = [...nextMessages, { role: "assistant", - content: assistantText, + content: processed.content, + reasoning: processed.reasoning, + thinkingDuration: finalThinkingDuration, generationStats, }]; setMessages(finalMessages); @@ -594,54 +720,70 @@ function TextChat({
)} - {messages.map((message, index) => ( -
- {/* Avatar */} -
- {message.role === "user" ? "You" : "AI"} -
+ {messages.map((message, index) => { + const processed = processMessageContent( + Array.isArray(message.content) ? "" : (message.content || ""), + message.reasoning || "" + ); + const displayContent = Array.isArray(message.content) ? message.content : processed.content; + const displayReasoning = processed.reasoning; - {/* Bubble + stats */} -
- - {message.role === "user" ? "You" : "Local AI"} - -
- {Array.isArray(message.content) ? ( -
- {message.content.map((item, idx) => { - if (item.type === "text") return ; - if (item.type === "image_url") return ( - Attached image - ); - return null; - })} -
- ) : ( - - )} + return ( +
+ {/* Avatar */} +
+ {message.role === "user" ? "You" : "AI"}
- {/* Generation stats pill */} - {message.role === "assistant" && message.generationStats && !message.error && ( -
- {message.generationStats.status === "starting" ? ( - <> Waiting for first token... - ) : message.generationStats.status === "streaming" ? ( - <>⚡ {message.generationStats.tokensPerSecond.toFixed(1)} tok/s + {/* Bubble + stats */} +
+ + {message.role === "user" ? "You" : "Local AI"} + + {message.role === "assistant" && displayReasoning && textSettings?.enableThinking !== false && ( + + )} +
+ {Array.isArray(displayContent) ? ( +
+ {displayContent.map((item, idx) => { + if (item.type === "text") return ; + if (item.type === "image_url") return ( + Attached image + ); + return null; + })} +
) : ( - <>{message.generationStats.tokens} tokens • {formatGenerationTime(message.generationStats.seconds)} + )}
- )} + + {/* Generation stats pill */} + {message.role === "assistant" && message.generationStats && !message.error && ( +
+ {message.generationStats.status === "starting" ? ( + <> Waiting for first token... + ) : message.generationStats.status === "streaming" ? ( + <>⚡ {message.generationStats.tokensPerSecond.toFixed(1)} tok/s + ) : ( + <>{message.generationStats.tokens} tokens • {formatGenerationTime(message.generationStats.seconds)} + )} +
+ )} +
-
- ))} + ); + })} )}
@@ -691,27 +833,69 @@ function TextChat({ onClick={() => fileInputRef.current?.click()} disabled={!supportsVision || isBusy} title={supportsVision ? "Attach files or images" : "Image attachment requires a vision model with an mmproj file"} + style={{ marginBottom: "2px" }} > -