Continuo su chat backend/frontend con nuovo modello proxy e frontend
This commit is contained in:
+79
-132
@@ -1,177 +1,124 @@
|
||||
// src/App.jsx
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import MessageContent from './MessageContent';
|
||||
import { useStreamBuffer } from './hooks/useStreamBuffer'
|
||||
import './App.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import React, { useState } from "react";
|
||||
import "./App.css";
|
||||
import { themes } from "./themes";
|
||||
import ChatWindow from "./ChatWindow";
|
||||
import ChatInput from "./ChatInput";
|
||||
|
||||
|
||||
function App() {
|
||||
export default function App() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const { buffered, pushChunk, reset } = useStreamBuffer(80);
|
||||
const sendMessage = async () => {
|
||||
const [themeName, setThemeName] = useState("light");
|
||||
|
||||
const theme = themes[themeName];
|
||||
|
||||
const sendMessage = async (input) => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMessage = { role: "user", content: input };
|
||||
const userId = "user1";
|
||||
const userId = "userTest";
|
||||
const assistantIndex = messages.length + 1;
|
||||
|
||||
// Calculate where the assistant placeholder will land
|
||||
const startIndex = messages.length;
|
||||
const assistantIndex = startIndex + 1;
|
||||
|
||||
// Optimistic UI: user + empty assistant
|
||||
setMessages(prev => [...prev, userMessage, { role: "assistant", content: "" }]);
|
||||
setInput("");
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
userMessage,
|
||||
{ role: "assistant", content: "" },
|
||||
]);
|
||||
setLoading(true);
|
||||
reset(); // clear the buffer for the new response
|
||||
|
||||
try {
|
||||
const res = await fetch("/chat-stream", {
|
||||
const res = await fetch("/v1/chat-stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
|
||||
body: JSON.stringify({ user_id: userId, message: userMessage.content })
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId, message: userMessage.content }),
|
||||
});
|
||||
|
||||
// Non-streaming fallback
|
||||
if (!res.ok || !res.body) {
|
||||
const json = await res.json().catch(() => null);
|
||||
const text = json?.response ?? "Error: streaming not available.";
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: text };
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let acc = ""; // final committed text
|
||||
let sseBuffer = ""; // raw SSE buffer
|
||||
let buffer = "";
|
||||
let acc = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Split on SSE event boundaries
|
||||
const events = sseBuffer.split("\n\n");
|
||||
sseBuffer = events.pop() || "";
|
||||
|
||||
for (const evt of events) {
|
||||
const lines = evt.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
// Commit final text and finish
|
||||
setMessages(prev => {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() || "";
|
||||
for (const part of parts) {
|
||||
const line = part
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith("data:"));
|
||||
if (!line) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: acc };
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(data);
|
||||
const choice = obj?.choices?.[0] ?? {};
|
||||
const delta = choice.delta ?? {};
|
||||
const piece = delta.content ?? choice.text ?? "";
|
||||
if (piece) {
|
||||
acc += piece;
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: acc };
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(data);
|
||||
const choice = obj?.choices?.[0] ?? {};
|
||||
const delta = choice.delta ?? {};
|
||||
const piece = delta.content ?? choice.text ?? "";
|
||||
if (piece) {
|
||||
acc += piece; // reliable final copy
|
||||
pushChunk(piece); // smooth UI copy
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON control lines
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended without an explicit [DONE]
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: acc };
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setMessages(prev => {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: `Error: ${String(err)}` };
|
||||
next[assistantIndex] = {
|
||||
role: "assistant",
|
||||
content: `Error: ${String(err)}`,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [buffered, loading]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setThemeName((t) => (t === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-container d-flex flex-column vh-100">
|
||||
<header className="navbar navbar-dark bg-primary sticky-top">
|
||||
<div className="container-fluid">
|
||||
<span className="navbar-brand mb-0 h1">Egalware's LM Studio Chat</span>
|
||||
</div>
|
||||
<div className={`d-flex flex-column vh-100 ${theme.bodyBg}`}>
|
||||
<header className={`${theme.headerBg} text-center py-3 sticky-top shadow row`}>
|
||||
<div class="col-3"></div>
|
||||
<div class="col-6">
|
||||
<h5 className="my-0">🤖 EgalWare's LLM ChatBot</h5>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<button className="btn btn-sm btn-outline-light mt-2" onClick={toggleTheme}>
|
||||
Toggle Theme
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="chat-box container-fluid py-2 flex-grow-1 overflow-auto">
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant = i === messages.length - 1 && msg.role === "assistant" && loading;
|
||||
return (
|
||||
<div className="row mb-2" key={i}>
|
||||
<div className={`col-12 d-flex ${msg.role === "user" ? "justify-content-end" : "justify-content-start"}`}>
|
||||
<div className={`p-2 rounded-3 shadow-sm ${ msg.role === "user" ? "bg-primary bg-opacity-75 text-white" : "bg-light border text-dark" }`} style={{ width: "95%" }} >
|
||||
<MessageContent content={isLastAssistant ? buffered : msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ChatWindow messages={messages} loading={loading} theme={theme} />
|
||||
|
||||
{loading && (
|
||||
<div className="row text-muted ps-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status" />
|
||||
The model is processing...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="input-bar d-flex justify-content-center p-3 bg-light border-top">
|
||||
<div className="w-100 w-md-75 w-lg-50 d-flex">
|
||||
<input
|
||||
className="form-control me-2"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && sendMessage()}
|
||||
placeholder="Type your message..."
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={sendMessage} disabled={loading}>
|
||||
{loading ? "Sending..." : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput onSend={sendMessage} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// src/App.jsx
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import MessageContent from './MessageContent';
|
||||
import { useStreamBuffer } from './hooks/useStreamBuffer'
|
||||
import './App.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
|
||||
function App() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const { buffered, pushChunk, reset } = useStreamBuffer(80);
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMessage = { role: "user", content: input };
|
||||
const userId = "user1";
|
||||
|
||||
// Calculate where the assistant placeholder will land
|
||||
const startIndex = messages.length;
|
||||
const assistantIndex = startIndex + 1;
|
||||
|
||||
// Optimistic UI: user + empty assistant
|
||||
setMessages(prev => [...prev, userMessage, { role: "assistant", content: "" }]);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
reset(); // clear the buffer for the new response
|
||||
|
||||
try {
|
||||
const res = await fetch("/v1/chat-stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
|
||||
body: JSON.stringify({ user_id: userId, message: userMessage.content })
|
||||
});
|
||||
|
||||
// Non-streaming fallback
|
||||
if (!res.ok || !res.body) {
|
||||
const json = await res.json().catch(() => null);
|
||||
const text = json?.response ?? "Error: streaming not available.";
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: text };
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let acc = ""; // final committed text
|
||||
let sseBuffer = ""; // raw SSE buffer
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Split on SSE event boundaries
|
||||
const events = sseBuffer.split("\n\n");
|
||||
sseBuffer = events.pop() || "";
|
||||
|
||||
for (const evt of events) {
|
||||
const lines = evt.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (data === "[DONE]") {
|
||||
// Commit final text and finish
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: acc };
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(data);
|
||||
const choice = obj?.choices?.[0] ?? {};
|
||||
const delta = choice.delta ?? {};
|
||||
const piece = delta.content ?? choice.text ?? "";
|
||||
if (piece) {
|
||||
acc += piece; // reliable final copy
|
||||
pushChunk(piece); // smooth UI copy
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON control lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended without an explicit [DONE]
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: acc };
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
next[assistantIndex] = { role: "assistant", content: `Error: ${String(err)}` };
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [buffered, loading]);
|
||||
|
||||
return (
|
||||
<div className="chat-container d-flex flex-column vh-100">
|
||||
<header className="navbar navbar-dark bg-primary sticky-top">
|
||||
<div className="container-fluid">
|
||||
<span className="navbar-brand mb-0 h1">Egalware's LM Studio Chat</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="chat-box container-fluid py-2 flex-grow-1 overflow-auto">
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant = i === messages.length - 1 && msg.role === "assistant" && loading;
|
||||
return (
|
||||
<div className="row mb-2" key={i}>
|
||||
<div className={`col-12 d-flex ${msg.role === "user" ? "justify-content-end" : "justify-content-start"}`}>
|
||||
<div className={`p-2 rounded-3 shadow-sm ${ msg.role === "user" ? "bg-primary bg-opacity-75 text-white" : "bg-light border text-dark" }`} style={{ width: "95%" }} >
|
||||
<MessageContent content={isLastAssistant ? buffered : msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{loading && (
|
||||
<div className="row text-muted ps-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status" />
|
||||
The model is processing...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="input-bar d-flex justify-content-center p-3 bg-light border-top">
|
||||
<div className="w-100 w-md-75 w-lg-50 d-flex">
|
||||
<input
|
||||
className="form-control me-2"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && sendMessage()}
|
||||
placeholder="Type your message..."
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={sendMessage} disabled={loading}>
|
||||
{loading ? "Sending..." : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
|
||||
export default function AssistantMessage({ content, theme }) {
|
||||
return (
|
||||
<div className="mb-2 text-start">
|
||||
<div
|
||||
className={`d-inline-block p-2 rounded ${theme.assistantBg}`}
|
||||
style={{ maxWidth: "95%" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
table: (props) => (
|
||||
<table className="table table-sm table-bordered" {...props} />
|
||||
),
|
||||
th: (props) => <th className="bg-light" {...props} />
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function ChatInput({ onSend, loading }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
onSend(value);
|
||||
setValue("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 border-top bg-white">
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (Shift+Enter for newline)"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import UserMessage from "./UserMessage";
|
||||
import AssistantMessage from "./AssistantMessage";
|
||||
|
||||
export default function ChatWindow({ messages, loading, theme }) {
|
||||
const endRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<main className={`flex-grow-1 overflow-auto p-3 ${theme.bodyBg}`}>
|
||||
{messages.map((m, idx) =>
|
||||
m.role === "user" ? (
|
||||
<UserMessage key={idx} content={m.content} theme={theme} />
|
||||
) : (
|
||||
<AssistantMessage key={idx} content={m.content} theme={theme} />
|
||||
)
|
||||
)}
|
||||
{loading && (
|
||||
<div className="text-muted small fst-italic">
|
||||
The model is processing...
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
export default function UserMessage({ content, theme }) {
|
||||
return (
|
||||
<div className="mb-2 text-end">
|
||||
<div
|
||||
className={`d-inline-block p-2 rounded ${theme.userBg}`}
|
||||
style={{ maxWidth: "95%" }}
|
||||
>
|
||||
<pre
|
||||
className="m-0"
|
||||
style={{ whiteSpace: "pre-wrap", fontFamily: "inherit" }}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// themes.js
|
||||
export const themes = {
|
||||
light: {
|
||||
userBg: "bg-primary text-white",
|
||||
assistantBg: "bg-white border text-dark",
|
||||
bodyBg: "bg-light",
|
||||
headerBg: "bg-primary text-white",
|
||||
},
|
||||
dark: {
|
||||
userBg: "bg-dark text-white",
|
||||
assistantBg: "bg-secondary text-white",
|
||||
bodyBg: "bg-black text-white",
|
||||
headerBg: "bg-dark text-white",
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user