Update grafico frontend

This commit is contained in:
Samuele E. Locatelli
2025-08-21 14:25:53 +00:00
parent c5d9af2573
commit a92c3e6dee
6 changed files with 606 additions and 85 deletions
+70 -15
View File
@@ -1,8 +1,10 @@
# main.py
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
import requests
from fastapi.responses import StreamingResponse
import redis
import json
import httpx
r = redis.Redis(host='localhost', port=6379, db=0)
@@ -15,7 +17,6 @@ def get_chat(user_id):
app = FastAPI()
# Allow frontend access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -23,8 +24,8 @@ app.add_middleware(
allow_headers=["*"],
)
# Store sessions in memory (simple version)
sessions = {}
LM_STUDIO_URL = "http://10.74.83.100:1234/v1/chat/completions"
MODEL_NAME = "qwen/qwen3-4b-2507" # update as needed
@app.post("/chat")
async def chat(request: Request):
@@ -32,21 +33,75 @@ async def chat(request: Request):
user_id = data.get("user_id", "default")
message = data["message"]
# Retrieve session history
save_chat(user_id, {"role": "user", "content": message})
history = get_chat(user_id)
# Send to LM Studio
response = requests.post("http://10.74.83.100:1234/v1/chat/completions", json={
"model": "qwen/qwen3-4b-2507", # Replace with actual model name
"messages": history
})
result = response.json()
async with httpx.AsyncClient(timeout=None) as client:
resp = await client.post(LM_STUDIO_URL, json={
"model": MODEL_NAME,
"messages": history,
})
result = resp.json()
reply = result["choices"][0]["message"]["content"]
# save in REDIS chat history
save_chat(user_id, {"role": "assistant", "content": reply})
return {"response": reply}
@app.post("/chat-stream")
async def chat_stream(request: Request):
data = await request.json()
user_id = data.get("user_id", "default")
message = data["message"]
# Save user message and build history
save_chat(user_id, {"role": "user", "content": message})
history = get_chat(user_id)
async def event_generator():
assistant_text = ""
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", LM_STUDIO_URL, json={
"model": MODEL_NAME,
"messages": history,
"stream": True
}) as r:
async for raw_line in r.aiter_lines():
if not raw_line:
continue
# Normalize to standard SSE "data: ..." form
line = raw_line if raw_line.startswith("data:") else f"data: {raw_line}"
payload = line[len("data: "):].strip()
if payload == "[DONE]":
# Finalize and flush
yield "data: [DONE]\n\n"
break
# Echo the SSE line to client
yield f"data: {payload}\n\n"
# Accumulate content for saving to Redis
try:
obj = json.loads(payload)
choice = obj.get("choices", [{}])[0]
# Handle OpenAI-style streaming objects
delta = choice.get("delta", {})
piece = delta.get("content")
if piece is None:
# Some servers send "text" instead
piece = choice.get("text")
if piece:
assistant_text += piece
except Exception:
# Ignore non-JSON control lines
pass
finally:
if assistant_text:
save_chat(user_id, {"role": "assistant", "content": assistant_text})
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # helps if behind Nginx
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
+311
View File
@@ -8,9 +8,12 @@
"name": "lm-chat-frontend",
"version": "1.0.0",
"dependencies": {
"katex": "^0.16.22",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"vite": "^4.0.0"
},
"devDependencies": {
@@ -66,6 +69,12 @@
"@types/unist": "*"
}
},
"node_modules/@types/katex": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -265,6 +274,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -331,6 +349,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.18.20",
"hasInstallScript": true,
@@ -406,6 +436,101 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/hast-util-from-dom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
"license": "ISC",
"dependencies": {
"@types/hast": "^3.0.0",
"hastscript": "^9.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.1.0",
"hast-util-from-parse5": "^8.0.0",
"parse5": "^7.0.0",
"vfile": "^6.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html-isomorphic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-dom": "^5.0.0",
"hast-util-from-html": "^2.0.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -433,6 +558,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unist-util-find-after": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -446,6 +587,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -522,6 +680,22 @@
"version": "4.0.0",
"license": "MIT"
},
"node_modules/katex": {
"version": "0.16.22",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
"integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -566,6 +740,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-math": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
"integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"longest-streak": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.1.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -764,6 +957,25 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-math": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
"license": "MIT",
"dependencies": {
"@types/katex": "^0.16.0",
"devlop": "^1.0.0",
"katex": "^0.16.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -1201,6 +1413,18 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@@ -1302,6 +1526,41 @@
"react": ">=18"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
"integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/katex": "^0.16.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.0",
"katex": "^0.16.0",
"unist-util-visit-parents": "^6.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-math": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
"integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-math": "^3.0.0",
"micromark-extension-math": "^3.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -1451,6 +1710,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-find-after": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -1477,6 +1750,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-remove-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -1564,6 +1851,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -1631,6 +1932,16 @@
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+3
View File
@@ -2,9 +2,12 @@
"name": "lm-chat-frontend",
"version": "1.0.0",
"dependencies": {
"katex": "^0.16.22",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"vite": "^4.0.0"
},
"scripts": {
+36 -10
View File
@@ -1,8 +1,4 @@
/* src/App.css */
body {
font-family: sans-serif;
}
body, html {
margin: 0;
padding: 0;
@@ -21,25 +17,39 @@ body, html {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: #f8f9fa; /* Bootstrap light gray */
}
/* Wider bubbles — use Bootstrap breakpoints for responsiveness */
.message {
margin: 0.5rem 0;
padding: 0.75rem;
border-radius: 8px;
max-width: 80%;
padding: 0.9rem 1rem;
border-radius: 12px;
width: auto;
max-width: 95%; /* was 80% */
word-wrap: break-word;
}
@media (min-width: 768px) {
.message {
max-width: 75%; /* keep some margin on larger screens */
}
}
.message.user {
background-color: #dbeafe;
background-color: #0d6efd; /* Bootstrap primary */
color: #fff;
align-self: flex-end;
text-align: right;
border-top-right-radius: 0; /* subtle speech bubble effect */
}
.message.assistant {
background-color: #e5e7eb;
background-color: #e9ecef; /* Bootstrap light gray */
color: #212529;
align-self: flex-start;
text-align: left;
border-top-left-radius: 0;
}
.input-bar {
@@ -60,10 +70,26 @@ body, html {
.send-button {
margin-left: 0.5rem;
padding: 0.75rem 1rem;
background-color: #3b82f6;
background-color: #0d6efd; /* Bootstrap primary */
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
pre {
background-color: #212529; /* dark background for code */
color: #f8f9fa;
padding: 0.75rem;
border-radius: 0.375rem;
overflow-x: auto;
}
code {
font-family: 'Fira Code', monospace;
font-size: 0.9rem;
}
.btn-copy {
font-size: 0.75rem;
}
+146 -60
View File
@@ -1,81 +1,167 @@
// 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 './App.css';
import 'katex/dist/katex.min.css';
function App() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const messagesEndRef = useRef(null);
const [loading, setLoading] = useState(false);
const sendMessage = async () => {
if (!input.trim()) return;
const userMessage = { role: "user", content: input };
setMessages(prev => [...prev, userMessage]);
setInput("");
setLoading(true); // 🚀 show loader
try {
const res = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: "user1", message: input })
});
const data = await res.json();
const botMessage = { role: "assistant", content: data.response };
setMessages(prev => [...prev, botMessage]);
} finally {
setLoading(false); // ✅ hide loader
}
};
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div className="chat-container">
<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">
{loading && (
<div className="d-flex justify-content-start p-2">
<div className="spinner-border text-secondary" role="status" style={{width: "1.5rem", height: "1.5rem"}}>
<span className="visually-hidden">Thinking...</span>
</div>
<span className="ms-2 text-muted">The model is processing...</span>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`d-flex mb-2 ${msg.role === "user" ? "justify-content-end" : "justify-content-start"}`}>
<div className={`p-2 rounded shadow-sm ${msg.role === "user" ? "bg-primary text-white" : "bg-light text-dark border" }`} style={{ maxWidth: "75%" }}>
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
</div>
))}
const sendMessage = async () => {
if (!input.trim() || loading) return;
<div ref={messagesEndRef} />
const userMessage = { role: "user", content: input };
const userId = "user1";
setMessages(prev => [...prev, userMessage, { role: "assistant", content: "" }]);
setInput("");
setLoading(true);
const controller = new AbortController();
const signal = controller.signal;
try {
const res = await fetch("/chat-stream", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
body: JSON.stringify({ user_id: userId, message: userMessage.content }),
signal
});
if (!res.ok) {
// Fallback: non-streaming endpoint if server doesnt stream
const json = await res.json().catch(() => null);
const text = json?.response ?? "Error: streaming not available.";
setMessages(prev => {
const next = [...prev];
next[next.length - 1] = { role: "assistant", content: text };
return next;
});
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let gotFirstToken = false;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split on SSE event boundary
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const evt of events) {
// Each event may contain multiple lines; process data lines
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]") {
// Stream end
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) {
if (!gotFirstToken) {
gotFirstToken = true;
}
setMessages(prev => {
const next = [...prev];
const lastIndex = next.length - 1;
const last = next[lastIndex];
next[lastIndex] = { ...last, content: (last.content || "") + piece };
return next;
});
}
} catch {
// Non-JSON control lines (ignore)
}
}
}
}
} catch (err) {
setMessages(prev => {
const next = [...prev];
next[next.length - 1] = { role: "assistant", content: `Error: ${String(err)}` };
return next;
});
} finally {
setLoading(false);
}
};
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&apos;s LM Studio Chat</span>
</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..."
autoFocus
/>
<button className="btn btn-primary" onClick={sendMessage}>Send</button>
</div>
</div>
</header>
<div className="chat-box container-fluid py-2 flex-grow-1 overflow-auto">
{messages.map((msg, i) => (
<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%" }} >
<ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>{msg.content}</ReactMarkdown>
</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;
+40
View File
@@ -0,0 +1,40 @@
import React, { useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
export default function MessageContent({ content }) {
const handleCopy = useCallback((text) => {
navigator.clipboard.writeText(text);
}, []);
return (
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ inline, className, children, ...props }) {
const codeText = String(children).replace(/\n$/, '');
if (inline) {
return <code className={className} {...props}>{children}</code>;
}
return (
<div className="position-relative">
<pre className={className} {...props}>{children}</pre>
<button
className="btn btn-sm btn-outline-secondary position-absolute top-0 end-0 m-1"
onClick={() => handleCopy(codeText)}
>
Copy
</button>
</div>
);
}
}}
>
{content}
</ReactMarkdown>
);
}