add text chat UI with LLM configuration and infrastructure support for the new backend port
This commit is contained in:
1 parent
55e01f137e
commit
8fa0a670c1
10 files changed
+631
-195
No files matched your search
+114
-33
@@ -677,6 +677,10 @@ body {
|
||||
animation: msgSlideIn 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.chat-message-row.ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@keyframes msgSlideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -714,7 +718,9 @@ body {
|
||||
.chat-avatar.ai {
|
||||
background: linear-gradient(135deg, var(--md-sys-color-primary), color-mix(in srgb, var(--md-sys-color-primary) 70%, #7c3aed));
|
||||
color: var(--md-sys-color-on-primary);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent),
|
||||
0 8px 20px color-mix(in srgb, var(--md-sys-color-primary) 28%, transparent);
|
||||
}
|
||||
|
||||
.chat-avatar.user {
|
||||
@@ -728,7 +734,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: min(1180px, 92%);
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chat-message-row.user .chat-bubble-wrap {
|
||||
@@ -777,12 +783,13 @@ body {
|
||||
|
||||
/* Sender label */
|
||||
.chat-sender-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--md-sys-color-outline);
|
||||
padding: 0 2px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* ─── Generation Stats ────────────────────────────────────────── */
|
||||
@@ -802,23 +809,23 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 3px 10px;
|
||||
margin-top: 2px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
background: color-mix(in srgb, var(--md-sys-color-surface-variant) 70%, transparent);
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--md-sys-color-surface-variant) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, var(--md-sys-color-primary) 12%);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(4px);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
backdrop-filter: blur(8px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-generation-stats.streaming {
|
||||
color: var(--md-sys-color-primary);
|
||||
border-color: color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--md-sys-color-primary) 8%, var(--md-sys-color-surface-variant));
|
||||
animation: statsGlow 1.5s ease-in-out infinite;
|
||||
border-color: color-mix(in srgb, var(--md-sys-color-primary) 38%, transparent);
|
||||
background: color-mix(in srgb, var(--md-sys-color-primary) 10%, var(--md-sys-color-surface-variant));
|
||||
box-shadow: 0 6px 18px color-mix(in srgb, var(--md-sys-color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
@keyframes statsGlow {
|
||||
@@ -2582,54 +2589,128 @@ html[data-theme^="dark"] .m3-segment-item:hover:not(.active):not(.disabled) {
|
||||
|
||||
/* Chat Thinking Block (Reasoning Process) */
|
||||
.chat-thinking-container {
|
||||
margin-bottom: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
margin-bottom: 4px;
|
||||
width: fit-content;
|
||||
max-width: min(420px, 100%);
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--md-sys-color-primary) 24%, var(--border-color));
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--md-sys-color-primary) 10%, var(--md-sys-color-surface-variant)),
|
||||
color-mix(in srgb, var(--md-sys-color-surface-variant) 88%, transparent));
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--md-sys-color-primary) 9%, transparent);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.chat-thinking-container:has(.chat-thinking-content) {
|
||||
border-radius: 13px;
|
||||
max-width: min(720px, 92vw);
|
||||
}
|
||||
|
||||
.chat-thinking-header {
|
||||
width: 100%;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
gap: 10px;
|
||||
padding: 7px 11px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-thinking-header:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: color-mix(in srgb, var(--md-sys-color-primary) 7%, transparent);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.chat-thinking-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0.8;
|
||||
gap: 8px;
|
||||
opacity: 0.92;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-thinking-title::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--md-sys-color-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--md-sys-color-primary) 14%, transparent);
|
||||
animation: thinkingPulse 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes thinkingPulse {
|
||||
0%, 100% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.72;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-thinking-content {
|
||||
padding: 8px 10px;
|
||||
padding: 9px 12px 10px;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
opacity: 0.75;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.78;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-family-monospace, monospace);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
background: color-mix(in srgb, var(--md-sys-color-surface) 28%, transparent);
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color:
|
||||
color-mix(in srgb, var(--md-sys-color-primary) 38%, var(--md-sys-color-outline))
|
||||
color-mix(in srgb, var(--md-sys-color-surface) 65%, transparent);
|
||||
}
|
||||
|
||||
.chat-thinking-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.chat-thinking-content::-webkit-scrollbar-track {
|
||||
background: color-mix(in srgb, var(--md-sys-color-surface) 60%, transparent);
|
||||
border-left: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.chat-thinking-content::-webkit-scrollbar-thumb {
|
||||
min-height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--md-sys-color-primary) 62%, #ffffff 8%),
|
||||
color-mix(in srgb, var(--md-sys-color-primary) 38%, var(--md-sys-color-outline))
|
||||
)
|
||||
border-box;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.chat-thinking-content::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--md-sys-color-primary) 78%, #ffffff 10%),
|
||||
color-mix(in srgb, var(--md-sys-color-primary) 52%, var(--md-sys-color-outline))
|
||||
)
|
||||
border-box;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -122,7 +122,8 @@ function App() {
|
||||
return {
|
||||
...parsed,
|
||||
enableThinking: parsed.enableThinking === true,
|
||||
maxTokens: parsed.maxTokens || 384,
|
||||
maxTokens: !parsed.maxTokens || parsed.maxTokens === 384 ? 1024 : parsed.maxTokens,
|
||||
responseTokenMode: parsed.responseTokenMode || "auto",
|
||||
};
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -134,7 +135,8 @@ function App() {
|
||||
enableThinking: false,
|
||||
// New performance settings
|
||||
gpuLayers: -1,
|
||||
maxTokens: 384,
|
||||
maxTokens: 1024,
|
||||
responseTokenMode: "auto",
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
minP: 0.05,
|
||||
@@ -167,10 +169,42 @@ function App() {
|
||||
if (saved) {
|
||||
try {
|
||||
setConversations(JSON.parse(saved));
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
localStorage.removeItem("chat_conversations");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sanitizeMessageForStorage = (message) => {
|
||||
if (!Array.isArray(message?.content)) return message;
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((item) => {
|
||||
if (item?.type !== "image_url") return item;
|
||||
return {
|
||||
type: "text",
|
||||
text: "[Attached image omitted from saved chat history]",
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeConversationForStorage = (conversation) => ({
|
||||
...conversation,
|
||||
messages: Array.isArray(conversation.messages)
|
||||
? conversation.messages.map(sanitizeMessageForStorage)
|
||||
: [],
|
||||
});
|
||||
|
||||
const persistConversations = (list) => {
|
||||
const compactList = list.map(sanitizeConversationForStorage);
|
||||
try {
|
||||
localStorage.setItem("chat_conversations", JSON.stringify(compactList));
|
||||
} catch (err) {
|
||||
console.warn("Could not save chat history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConversationState = useCallback((id, msgs, modelName, newTitle = null) => {
|
||||
setConversations((prev) => {
|
||||
const list = [...prev];
|
||||
@@ -192,7 +226,7 @@ function App() {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
localStorage.setItem("chat_conversations", JSON.stringify(list));
|
||||
persistConversations(list);
|
||||
return list;
|
||||
});
|
||||
}, []);
|
||||
@@ -201,7 +235,7 @@ function App() {
|
||||
if (e) e.stopPropagation();
|
||||
setConversations((prev) => {
|
||||
const filtered = prev.filter((c) => c.id !== id);
|
||||
localStorage.setItem("chat_conversations", JSON.stringify(filtered));
|
||||
persistConversations(filtered);
|
||||
return filtered;
|
||||
});
|
||||
if (activeConversationId === id) {
|
||||
|
||||
@@ -249,6 +249,7 @@ function ModelManager({
|
||||
const [hasMoreModels, setHasMoreModels] = useState(false);
|
||||
const [isLoadingMoreModels, setIsLoadingMoreModels] = useState(false);
|
||||
const searchRequestRef = React.useRef(0);
|
||||
const pendingCompanionDownloadRef = React.useRef(null);
|
||||
|
||||
const cancelLoadRef = React.useRef(false);
|
||||
|
||||
@@ -261,7 +262,10 @@ function ModelManager({
|
||||
}
|
||||
};
|
||||
|
||||
const modelNames = localModels.map((model) => normalizeModel(model).filename);
|
||||
const normalizedLocalModels = localModels.map(normalizeModel).filter((model) => model.filename);
|
||||
const displayedLocalModels = normalizedLocalModels.filter((model) => !model.isProjector);
|
||||
const allModelNames = normalizedLocalModels.map((model) => model.filename);
|
||||
const modelNames = displayedLocalModels.map((model) => model.filename);
|
||||
const isBusy = loadingModelId !== null || isUnloading;
|
||||
|
||||
const openvinoSupported = Boolean(backendOptions?.openvinoNpu?.supported);
|
||||
@@ -430,10 +434,10 @@ function ModelManager({
|
||||
}
|
||||
};
|
||||
|
||||
const downloadByUrl = async (url, expectedFilename) => {
|
||||
const downloadByUrl = async (url, expectedFilename, companion = null, options = {}) => {
|
||||
if (downloadingModelId) return;
|
||||
|
||||
if (modelNames.includes(expectedFilename)) {
|
||||
if (!options.skipExistingConfirm && allModelNames.includes(expectedFilename)) {
|
||||
if (!(await showConfirm({
|
||||
title: "Overwrite Model?",
|
||||
message: `"${expectedFilename}" is already downloaded. Do you want to download and overwrite it?`,
|
||||
@@ -452,6 +456,7 @@ function ModelManager({
|
||||
}
|
||||
|
||||
try {
|
||||
pendingCompanionDownloadRef.current = companion;
|
||||
let res;
|
||||
if (activeModelType === "image") {
|
||||
res = await downloadModel(url);
|
||||
@@ -461,9 +466,11 @@ function ModelManager({
|
||||
if (res && res.ok) {
|
||||
startProgressPolling(expectedFilename);
|
||||
} else {
|
||||
pendingCompanionDownloadRef.current = null;
|
||||
showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true });
|
||||
}
|
||||
} catch (err) {
|
||||
pendingCompanionDownloadRef.current = null;
|
||||
showAlert({ title: "Download Error", message: err.message, danger: true });
|
||||
}
|
||||
};
|
||||
@@ -486,11 +493,15 @@ function ModelManager({
|
||||
}
|
||||
} else {
|
||||
fetchModels();
|
||||
if (modelId === "ggml-model-q4_k.gguf") {
|
||||
const companion = pendingCompanionDownloadRef.current;
|
||||
pendingCompanionDownloadRef.current = null;
|
||||
if (companion?.url && companion?.filename && !allModelNames.includes(companion.filename)) {
|
||||
setTimeout(() => {
|
||||
downloadByUrl(
|
||||
"https://huggingface.co/mys/ggml_llava-v1.5-7b/resolve/main/mmproj-model-f16.gguf",
|
||||
"mmproj-model-f16.gguf"
|
||||
companion.url,
|
||||
companion.filename,
|
||||
null,
|
||||
{ skipExistingConfirm: true }
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
@@ -538,13 +549,35 @@ function ModelManager({
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMainModel = modelNames.includes(model.filename);
|
||||
const hasProjector = !model.projectorFilename || allModelNames.includes(model.projectorFilename);
|
||||
if (hasMainModel && model.projectorUrl && model.projectorFilename && !hasProjector) {
|
||||
const confirmed = await showConfirm({
|
||||
title: "Download Vision Projector?",
|
||||
message: `"${model.name}" is downloaded, but image input needs the matching projector file (${model.projectorFilename}). Download it now?`,
|
||||
confirmLabel: "Download Projector",
|
||||
});
|
||||
if (confirmed) {
|
||||
await downloadByUrl(model.projectorUrl, model.projectorFilename, null, { skipExistingConfirm: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm({
|
||||
title: "Download Model?",
|
||||
message: `Download "${model.name}" (~${model.approxSize})?`,
|
||||
message: model.projectorUrl
|
||||
? `Download "${model.name}" (~${model.approxSize}) and its required vision projector?`
|
||||
: `Download "${model.name}" (~${model.approxSize})?`,
|
||||
confirmLabel: "Download",
|
||||
});
|
||||
if (confirmed) {
|
||||
await downloadByUrl(model.url, model.filename);
|
||||
await downloadByUrl(
|
||||
model.url,
|
||||
model.filename,
|
||||
model.projectorUrl && model.projectorFilename
|
||||
? { url: model.projectorUrl, filename: model.projectorFilename }
|
||||
: null
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -627,7 +660,7 @@ function ModelManager({
|
||||
device: "",
|
||||
});
|
||||
const cores = textSettings?.threads || specs?.cpu_cores_physical || 4;
|
||||
const context = textSettings?.contextSize || 4096;
|
||||
const context = textSettings?.contextSize ?? 0;
|
||||
const res = await startLlm(modelId, {
|
||||
threads: cores,
|
||||
contextSize: context,
|
||||
@@ -1066,7 +1099,7 @@ function ModelManager({
|
||||
<div className="m3-card" style={{ marginTop: "24px" }}>
|
||||
<h3 className="m3-card-title">
|
||||
<Database size={18} style={{ color: "var(--md-sys-color-primary)" }} />
|
||||
Local {activeModelType === "image" ? "Image" : "Text"} Models ({localModels.length})
|
||||
Local {activeModelType === "image" ? "Image" : "Text"} Models ({displayedLocalModels.length})
|
||||
</h3>
|
||||
|
||||
{isLoadingModels ? (
|
||||
@@ -1074,7 +1107,7 @@ function ModelManager({
|
||||
<RefreshCw className="progress-spinner" size={16} />
|
||||
<span style={{ fontSize: "0.9rem" }}>Scanning models folder...</span>
|
||||
</div>
|
||||
) : localModels.length === 0 ? (
|
||||
) : displayedLocalModels.length === 0 ? (
|
||||
<p style={{ fontSize: "0.9rem", color: "var(--md-sys-color-outline)", textAlign: "center", padding: "16px 0" }}>
|
||||
{activeModelType === "image"
|
||||
? "No image models detected in app/models/. Download from the library below or import a file."
|
||||
@@ -1082,7 +1115,7 @@ function ModelManager({
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{localModels.map((model) => {
|
||||
{displayedLocalModels.map((model) => {
|
||||
const filename = model.filename;
|
||||
const isActive = activeModelType === "image" ? activeModel === filename : activeLlmModel === filename;
|
||||
|
||||
@@ -1221,8 +1254,11 @@ function ModelManager({
|
||||
<h4 style={{ fontWeight: 700, marginBottom: "12px" }}>{section.group}</h4>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "12px" }}>
|
||||
{section.items.map((model) => {
|
||||
const installed = modelNames.includes(model.filename);
|
||||
const downloading = downloadingModelId === model.filename || downloadingModelId === `${model.filename}.zip`;
|
||||
const hasMainModel = modelNames.includes(model.filename);
|
||||
const hasProjector = !model.projectorFilename || allModelNames.includes(model.projectorFilename);
|
||||
const needsProjector = Boolean(hasMainModel && model.projectorFilename && !hasProjector);
|
||||
const installed = hasMainModel && hasProjector;
|
||||
const downloading = downloadingModelId === model.filename || downloadingModelId === model.projectorFilename || downloadingModelId === `${model.filename}.zip`;
|
||||
const systemTier = getHardwareTier(specs);
|
||||
const isRecommended = typeof model.recommendedFit === "boolean"
|
||||
? model.recommendedFit
|
||||
@@ -1289,7 +1325,7 @@ function ModelManager({
|
||||
disabled={installed || downloadingModelId !== null}
|
||||
>
|
||||
{installed ? <HardDrive size={14} /> : downloading ? <RefreshCw className="progress-spinner" size={14} /> : <DownloadCloud size={14} />}
|
||||
<span>{installed ? "Downloaded" : downloading ? "Downloading" : "Download"}</span>
|
||||
<span>{installed ? "Downloaded" : downloading ? "Downloading" : needsProjector ? "Download Vision File" : "Download"}</span>
|
||||
</button>
|
||||
<a
|
||||
href={model.pageUrl || model.url}
|
||||
|
||||
@@ -232,7 +232,7 @@ function Settings({
|
||||
|
||||
const buildTextStartOptions = (settings) => ({
|
||||
threads: settings?.threads || specs?.cpu_cores_physical || 4,
|
||||
contextSize: settings?.contextSize || 4096,
|
||||
contextSize: settings?.contextSize ?? 0,
|
||||
gpuLayers: settings?.gpuLayers ?? -1,
|
||||
enableThinking: settings?.enableThinking !== false,
|
||||
flashAttn: settings?.flashAttn,
|
||||
@@ -685,17 +685,37 @@ function Settings({
|
||||
<div className="m3-slider-group">
|
||||
<div className="m3-slider-header">
|
||||
<span className="m3-slider-label">Max Response Tokens</span>
|
||||
<span className="settings-value-badge">{textSettings.maxTokens || 384}</span>
|
||||
<span className="settings-value-badge">
|
||||
{(textSettings.responseTokenMode || "auto") === "auto" ? "Auto" : (textSettings.maxTokens || 1024)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="m3-segmented-button" style={{ marginBottom: "10px" }}>
|
||||
{[
|
||||
{ id: "auto", label: "Auto" },
|
||||
{ id: "manual", label: "Manual" },
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
className={`m3-segment-item ${(textSettings.responseTokenMode || "auto") === mode.id ? "active" : ""}`}
|
||||
onClick={() => updateTextSetting("responseTokenMode", mode.id)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="m3-slider"
|
||||
value={textSettings.maxTokens || 384}
|
||||
value={textSettings.maxTokens || 1024}
|
||||
onChange={(e) => updateTextSetting("maxTokens", parseInt(e.target.value))}
|
||||
min="64"
|
||||
max="2048"
|
||||
max="4096"
|
||||
step="64"
|
||||
disabled={(textSettings.responseTokenMode || "auto") === "auto"}
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
|
||||
Auto uses remaining context with a safety buffer. Manual uses the slider value.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="m3-slider-group">
|
||||
|
||||
@@ -150,6 +150,17 @@ function TextChat({
|
||||
const wordCount = value.split(/\s+/).filter(Boolean).length;
|
||||
return Math.max(wordCount, Math.ceil(value.length / 4));
|
||||
};
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const getAutoMaxResponseTokens = (promptTokens, thinkingEnabled) => {
|
||||
const contextLimit = Number(status.settings?.contextSize || textSettings?.contextSize || 4096);
|
||||
const safetyBuffer = thinkingEnabled ? 768 : 512;
|
||||
const preferredMinimum = thinkingEnabled ? 512 : 256;
|
||||
const available = contextLimit - promptTokens - safetyBuffer;
|
||||
if (available <= 0) return 64;
|
||||
return clamp(available, Math.min(preferredMinimum, available), 4096);
|
||||
};
|
||||
|
||||
const bottomRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
@@ -192,14 +203,45 @@ function TextChat({
|
||||
return /\.(txt|md|csv|js|jsx|ts|tsx|py|json|css|html|java|cpp|c|h|rs|go|sh|bat|xml|yaml|yml)$/i.test(file.name) || file.type.startsWith("text/");
|
||||
};
|
||||
|
||||
const optimizeImageForVision = (file, maxSide = 1024, quality = 0.92) => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error(`Could not read ${file.name}`));
|
||||
reader.onload = () => {
|
||||
const originalDataUrl = reader.result;
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error(`Could not decode ${file.name}`));
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
|
||||
const width = Math.max(1, Math.round(img.width * scale));
|
||||
const height = Math.max(1, Math.round(img.height * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const sendDataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||
resolve({
|
||||
previewDataUrl: originalDataUrl,
|
||||
sendDataUrl,
|
||||
originalWidth: img.width,
|
||||
originalHeight: img.height,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
img.src = originalDataUrl;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
files.forEach((file) => {
|
||||
if (isImage(file)) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
optimizeImageForVision(file)
|
||||
.then((image) => {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -207,11 +249,18 @@ function TextChat({
|
||||
file,
|
||||
type: "image",
|
||||
name: file.name,
|
||||
dataUrl: event.target.result,
|
||||
dataUrl: image.previewDataUrl,
|
||||
sendDataUrl: image.sendDataUrl,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
originalWidth: image.originalWidth,
|
||||
originalHeight: image.originalHeight,
|
||||
},
|
||||
]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
.catch((err) => {
|
||||
showAlert({ title: "Image Error", message: err.message || String(err), danger: true });
|
||||
});
|
||||
} else if (isTextFile(file)) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
@@ -279,7 +328,7 @@ function TextChat({
|
||||
|
||||
const buildTextStartOptions = (settings) => ({
|
||||
threads: settings?.threads || specs?.cpu_cores_physical || 4,
|
||||
contextSize: settings?.contextSize || 4096,
|
||||
contextSize: settings?.contextSize ?? 0,
|
||||
gpuLayers: settings?.gpuLayers ?? -1,
|
||||
enableThinking: settings?.enableThinking !== false,
|
||||
flashAttn: settings?.flashAttn,
|
||||
@@ -360,13 +409,14 @@ function TextChat({
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextModels, nextStatus] = await Promise.all([listLlmModels(), getLlmStatus()]);
|
||||
setModels(nextModels);
|
||||
const selectableModels = nextModels.filter((model) => !model.isProjector);
|
||||
setModels(selectableModels);
|
||||
setStatus(nextStatus);
|
||||
const active = nextStatus.settings?.model;
|
||||
setSelectedModel((current) => {
|
||||
const saved = localStorage.getItem("selectedLlmModel");
|
||||
const savedExists = nextModels.some((m) => m.filename === saved);
|
||||
return active || current || (savedExists ? saved : "") || nextModels[0]?.filename || "";
|
||||
const savedExists = selectableModels.some((m) => m.filename === saved);
|
||||
return active || current || (savedExists ? saved : "") || selectableModels[0]?.filename || "";
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -453,7 +503,7 @@ function TextChat({
|
||||
|
||||
const result = await startLlm(filename, {
|
||||
threads: textSettings?.threads || specs?.cpu_cores_physical || 4,
|
||||
contextSize: textSettings?.contextSize || 4096,
|
||||
contextSize: textSettings?.contextSize ?? 0,
|
||||
gpuLayers: textSettings?.gpuLayers ?? -1,
|
||||
enableThinking: textSettings?.enableThinking !== false,
|
||||
flashAttn: textSettings?.flashAttn,
|
||||
@@ -512,15 +562,29 @@ function TextChat({
|
||||
documentContext += `\n[Attached File: ${att.name}]\n${att.content}\n`;
|
||||
});
|
||||
|
||||
const combinedText = documentContext ? `${text}\n\n${documentContext}`.trim() : text;
|
||||
const imageAttachments = attachments.filter(att => att.type === "image");
|
||||
const visionInstruction = imageAttachments.length > 0
|
||||
? "For attached images: answer in natural language, describe the actual image content, and read visible text carefully when relevant. Do not output raw JSON, bounding boxes, OCR layout objects, or detection labels unless the user explicitly asks for that format."
|
||||
: "";
|
||||
const requestText = imageAttachments.length > 0
|
||||
? (text && text !== "?" ? text : "Describe what is visible in the image.")
|
||||
: text;
|
||||
const displayText = text || (imageAttachments.length > 0 ? "Describe what is visible in the image." : "");
|
||||
const requestCombinedText = [
|
||||
requestText,
|
||||
documentContext,
|
||||
].filter(Boolean).join("\n\n").trim();
|
||||
const displayCombinedText = documentContext
|
||||
? [displayText, documentContext].filter(Boolean).join("\n\n").trim()
|
||||
: displayText;
|
||||
|
||||
let userMessageContent;
|
||||
let requestUserMessageContent;
|
||||
if (imageAttachments.length > 0) {
|
||||
userMessageContent = [
|
||||
{
|
||||
type: "text",
|
||||
text: combinedText
|
||||
text: displayCombinedText
|
||||
},
|
||||
...imageAttachments.map((img) => ({
|
||||
type: "image_url",
|
||||
@@ -529,11 +593,25 @@ function TextChat({
|
||||
}
|
||||
}))
|
||||
];
|
||||
requestUserMessageContent = [
|
||||
{
|
||||
type: "text",
|
||||
text: requestCombinedText
|
||||
},
|
||||
...imageAttachments.map((img) => ({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: img.sendDataUrl || img.dataUrl
|
||||
}
|
||||
}))
|
||||
];
|
||||
} else {
|
||||
userMessageContent = combinedText;
|
||||
userMessageContent = displayCombinedText;
|
||||
requestUserMessageContent = requestCombinedText;
|
||||
}
|
||||
|
||||
const nextMessages = [...messages, { role: "user", content: userMessageContent }];
|
||||
const requestConversationMessages = [...messages, { role: "user", content: requestUserMessageContent }];
|
||||
followGenerationRef.current = true;
|
||||
setMessages(nextMessages);
|
||||
setInput("");
|
||||
@@ -547,7 +625,7 @@ function TextChat({
|
||||
setMessages([...nextMessages, {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0 },
|
||||
generationStats: { status: "starting", tokens: 0, tokensPerSecond: 0, seconds: 0, vision: imageAttachments.length > 0 },
|
||||
}]);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -555,9 +633,13 @@ function TextChat({
|
||||
|
||||
try {
|
||||
const systemPrompt = textSettings?.systemPrompt || "You are a helpful local AI assistant.";
|
||||
const combinedSystemPrompt = [
|
||||
systemPrompt.trim(),
|
||||
visionInstruction,
|
||||
].filter(Boolean).join("\n\n");
|
||||
const requestMessages = [
|
||||
...(systemPrompt.trim() ? [{ role: "system", content: systemPrompt.trim() }] : []),
|
||||
...nextMessages,
|
||||
...(combinedSystemPrompt ? [{ role: "system", content: combinedSystemPrompt }] : []),
|
||||
...requestConversationMessages,
|
||||
];
|
||||
const promptTokenEstimate = requestMessages.reduce((sum, message) => {
|
||||
const messageText = Array.isArray(message.content)
|
||||
@@ -579,10 +661,11 @@ function TextChat({
|
||||
let thinkingEndedAt = null;
|
||||
let thinkingDuration = 0;
|
||||
|
||||
const baseMaxTokens = textSettings?.maxTokens || 384;
|
||||
const effectiveMaxTokens = textSettings?.enableThinking !== false
|
||||
? Math.max(baseMaxTokens, 1024)
|
||||
: baseMaxTokens;
|
||||
const thinkingEnabled = textSettings?.enableThinking !== false;
|
||||
const manualMaxTokens = textSettings?.maxTokens || 1024;
|
||||
const effectiveMaxTokens = textSettings?.responseTokenMode === "manual"
|
||||
? (thinkingEnabled ? Math.max(manualMaxTokens, 1024) : manualMaxTokens)
|
||||
: getAutoMaxResponseTokens(promptTokenEstimate, thinkingEnabled);
|
||||
|
||||
const response = await streamChatWithLlm(requestMessages, {
|
||||
temperature: textSettings?.temperature || 0.7,
|
||||
@@ -592,7 +675,7 @@ function TextChat({
|
||||
minP: textSettings?.minP,
|
||||
repeatPenalty: textSettings?.repeatPenalty,
|
||||
seed: textSettings?.seed,
|
||||
enableThinking: textSettings?.enableThinking !== false,
|
||||
enableThinking: thinkingEnabled,
|
||||
signal: controller.signal,
|
||||
}, (_token, fullText, _reasoningToken, fullReasoning) => {
|
||||
const now = performance.now();
|
||||
@@ -929,6 +1012,9 @@ function TextChat({
|
||||
);
|
||||
const displayContent = Array.isArray(message.content) ? message.content : processed.content;
|
||||
const displayReasoning = processed.reasoning;
|
||||
const hasDisplayContent = Array.isArray(displayContent)
|
||||
? displayContent.length > 0
|
||||
: Boolean(String(displayContent || "").trim());
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -952,35 +1038,37 @@ function TextChat({
|
||||
isComplete={!message.generationStats || message.generationStats.status === "complete"}
|
||||
/>
|
||||
)}
|
||||
<div className={`chat-bubble ${message.error ? "error" : ""}`}>
|
||||
{Array.isArray(displayContent) ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{displayContent.map((item, idx) => {
|
||||
if (item.type === "text") return <MarkdownRenderer key={idx} content={item.text} />;
|
||||
if (item.type === "image_url") return (
|
||||
<img key={idx} src={item.image_url.url} alt="Attached image"
|
||||
style={{ maxWidth: "240px", maxHeight: "180px", objectFit: "contain", borderRadius: "8px", marginTop: "4px" }}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={displayContent} />
|
||||
)}
|
||||
</div>
|
||||
{(hasDisplayContent || message.error) && (
|
||||
<div className={`chat-bubble ${message.error ? "error" : ""}`}>
|
||||
{Array.isArray(displayContent) ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{displayContent.map((item, idx) => {
|
||||
if (item.type === "text") return <MarkdownRenderer key={idx} content={item.text} />;
|
||||
if (item.type === "image_url") return (
|
||||
<img key={idx} src={item.image_url.url} alt="Attached image"
|
||||
style={{ maxWidth: "240px", maxHeight: "180px", objectFit: "contain", borderRadius: "8px", marginTop: "4px" }}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={displayContent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generation stats pill */}
|
||||
{message.role === "assistant" && message.generationStats && !message.error && (
|
||||
<>
|
||||
{message.generationStats.truncated && (
|
||||
<div className="chat-generation-warning">
|
||||
Response reached the token limit. Increase Max Response Tokens or ask "continue".
|
||||
Response reached the token limit. Ask "continue" or switch Max Response Tokens to Manual for a larger cap.
|
||||
</div>
|
||||
)}
|
||||
<div className={`chat-generation-stats ${message.generationStats.status}`}>
|
||||
{message.generationStats.status === "starting" ? (
|
||||
<><LoaderCircle size={11} className="progress-spinner" /> Waiting for first token...</>
|
||||
<><LoaderCircle size={11} className="progress-spinner" /> {message.generationStats.vision ? "Processing image..." : "Waiting for first token..."}</>
|
||||
) : message.generationStats.status === "streaming" ? (
|
||||
<><span style={{ opacity: 0.7 }}>⚡</span> {message.generationStats.tokensPerSecond.toFixed(1)} tok/s</>
|
||||
) : (
|
||||
|
||||
@@ -54,6 +54,7 @@ export function normalizeModel(model) {
|
||||
format: model?.format || "Local Weights File",
|
||||
backendType: model?.backendType || "",
|
||||
resolution: model?.resolution || "",
|
||||
isProjector: Boolean(model?.isProjector),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user