From a92c3e6deed47aa812f536020826cd1728984f66 Mon Sep 17 00:00:00 2001 From: "Samuele E. Locatelli" Date: Thu, 21 Aug 2025 14:25:53 +0000 Subject: [PATCH] Update grafico frontend --- backend/main.py | 85 +++++++-- frontend/package-lock.json | 311 ++++++++++++++++++++++++++++++++ frontend/package.json | 3 + frontend/src/App.css | 46 ++++- frontend/src/App.jsx | 206 +++++++++++++++------ frontend/src/MessageContent.jsx | 40 ++++ 6 files changed, 606 insertions(+), 85 deletions(-) create mode 100644 frontend/src/MessageContent.jsx diff --git a/backend/main.py b/backend/main.py index f51ea22..2b6a0d7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b1ece76..80b4c43 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index f291124..b611fee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.css b/frontend/src/App.css index ccab8e2..09ac1be 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e306b56..efc0205 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( -
-
-
- Egalware's LM Studio Chat -
-
-
- {loading && ( -
-
- Thinking... -
- The model is processing... -
- )} - {messages.map((msg, i) => ( -
-
- {msg.content} -
-
- ))} + const sendMessage = async () => { + if (!input.trim() || loading) return; -
+ 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 doesn’t 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 ( +
+
+
+ Egalware's LM Studio Chat
-
-
- setInput(e.target.value)} - onKeyDown={e => e.key === "Enter" && sendMessage()} - placeholder="Type your message..." - autoFocus - /> - -
-
+
+ +
+ {messages.map((msg, i) => ( +
+
+
+ {msg.content} +
+
+
+ ))} + + {loading && ( +
+
+
+ The model is processing... +
+
+ )} + +
+
+ +
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && sendMessage()} + placeholder="Type your message..." + disabled={loading} + autoFocus + /> + +
+
); } export default App; + diff --git a/frontend/src/MessageContent.jsx b/frontend/src/MessageContent.jsx new file mode 100644 index 0000000..363afc8 --- /dev/null +++ b/frontend/src/MessageContent.jsx @@ -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 ( + {children}; + } + return ( +
+
{children}
+ +
+ ); + } + }} + > + {content} +
+ ); +} +