From 2b2e5fcb0f56b27997ff290bfd626858365944dc Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Sun, 9 Aug 2026 18:52:19 +0900 Subject: [PATCH] Persist chat history locally --- .gitignore | 1 + README.md | 10 +++++- app.js | 62 ++++++++++++++++++++++++++++++++--- index.html | 13 ++------ server.py | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 165 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index a98520d..b6f55d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc .env +data/*.json diff --git a/README.md b/README.md index 2e4ab6e..3a32944 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # llm_web -Local LLM chat web page powered by Ollama. +Local LLM chat web page powered by Ollama and `qwen2.5:7b`. ## Run @@ -15,3 +15,11 @@ Open: ```text http://127.0.0.1:8787 ``` + +LAN access uses the notebook IP: + +```text +http://:8787 +``` + +Chat history is stored locally in `data/chats.json`. The `data/*.json` files are ignored by Git. diff --git a/app.js b/app.js index 67d905c..2005f37 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,10 @@ const promptInput = document.querySelector("#prompt"); const messagesEl = document.querySelector("#messages"); const sendButton = document.querySelector("#sendButton"); const newChatButton = document.querySelector("#newChat"); +const historyEl = document.querySelector("#history"); +const chatTitleEl = document.querySelector("#chatTitle"); +let activeChatId = null; let messages = []; function addMessage(role, content) { @@ -24,10 +27,53 @@ function addMessage(role, content) { return bubble; } -function resetChat() { - messages = []; +function renderMessages() { messagesEl.innerHTML = ""; - addMessage("assistant", "새 대화를 시작합니다."); + + if (!messages.length) { + addMessage("assistant", "안녕하세요. 이 노트북에서 실행되는 로컬 LLM입니다."); + return; + } + + messages.forEach((message) => addMessage(message.role, message.content)); +} + +function setTitle(title) { + chatTitleEl.textContent = title || "Local LLM"; +} + +async function loadHistory() { + const response = await fetch("/api/chats"); + const data = await response.json(); + historyEl.innerHTML = ""; + + data.chats.forEach((chat) => { + const item = document.createElement("button"); + item.className = `history-item ${chat.id === activeChatId ? "active" : ""}`; + item.type = "button"; + item.textContent = chat.title; + item.addEventListener("click", () => loadChat(chat.id)); + historyEl.append(item); + }); +} + +async function loadChat(chatId) { + const response = await fetch(`/api/chats/${chatId}`); + const data = await response.json(); + activeChatId = data.chat.id; + messages = data.chat.messages || []; + setTitle(data.chat.title); + renderMessages(); + await loadHistory(); + promptInput.focus(); +} + +function resetChat() { + activeChatId = null; + messages = []; + setTitle("Local LLM"); + renderMessages(); + loadHistory(); promptInput.focus(); } @@ -65,7 +111,7 @@ form.addEventListener("submit", async (event) => { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages }), + body: JSON.stringify({ chatId: activeChatId, messages }), }); const data = await response.json(); @@ -74,7 +120,10 @@ form.addEventListener("submit", async (event) => { } pending.textContent = data.reply; - messages.push({ role: "assistant", content: data.reply }); + activeChatId = data.chatId; + messages = data.chat.messages || [...messages, { role: "assistant", content: data.reply }]; + setTitle(data.chat.title); + await loadHistory(); } catch (error) { pending.textContent = `오류: ${error.message}`; } finally { @@ -82,3 +131,6 @@ form.addEventListener("submit", async (event) => { promptInput.focus(); } }); + +renderMessages(); +loadHistory(); diff --git a/index.html b/index.html index 7061347..28320b8 100644 --- a/index.html +++ b/index.html @@ -9,26 +9,19 @@
- Local LLM + Local LLM Ollama on this notebook
-
-
-
L
-
안녕하세요. 이 노트북에서 실행되는 로컬 LLM입니다.
-
-
+
diff --git a/server.py b/server.py index ffb581b..a8cbd0a 100644 --- a/server.py +++ b/server.py @@ -2,6 +2,8 @@ from __future__ import annotations import json import socket +import uuid +from datetime import datetime, timezone from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -9,15 +11,71 @@ import ollama ROOT = Path(__file__).parent +DATA_DIR = ROOT / "data" +CHATS_FILE = DATA_DIR / "chats.json" MODEL = "qwen2.5:7b" HOST = "0.0.0.0" PORT = 8787 +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def read_store() -> dict: + if not CHATS_FILE.exists(): + return {"chats": []} + + try: + return json.loads(CHATS_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"chats": []} + + +def write_store(store: dict) -> None: + DATA_DIR.mkdir(exist_ok=True) + CHATS_FILE.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8") + + +def chat_title(messages: list[dict]) -> str: + for message in messages: + if message.get("role") == "user" and message.get("content"): + title = " ".join(str(message["content"]).split()) + return title[:40] or "새 대화" + return "새 대화" + + class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(ROOT), **kwargs) + def do_GET(self) -> None: + if self.path == "/api/chats": + store = read_store() + chats = [ + { + "id": chat["id"], + "title": chat.get("title", "새 대화"), + "updatedAt": chat.get("updatedAt", ""), + } + for chat in store.get("chats", []) + ] + self._send_json(200, {"chats": chats}) + return + + if self.path.startswith("/api/chats/"): + chat_id = self.path.rsplit("/", 1)[-1] + store = read_store() + chat = next((item for item in store.get("chats", []) if item["id"] == chat_id), None) + if not chat: + self._send_json(404, {"error": "Chat not found"}) + return + + self._send_json(200, {"chat": chat}) + return + + super().do_GET() + def do_POST(self) -> None: if self.path != "/api/chat": self.send_error(404) @@ -25,6 +83,7 @@ class Handler(SimpleHTTPRequestHandler): length = int(self.headers.get("content-length", 0)) data = json.loads(self.rfile.read(length) or b"{}") + chat_id = data.get("chatId") messages = data.get("messages", []) try: @@ -38,11 +97,46 @@ class Handler(SimpleHTTPRequestHandler): *messages, ], ) - payload = {"reply": response["message"]["content"]} + reply = response["message"]["content"] + saved_messages = [*messages, {"role": "assistant", "content": reply}] + chat = self._save_chat(chat_id, saved_messages) + payload = {"chatId": chat["id"], "reply": reply, "chat": chat} self._send_json(200, payload) except Exception as exc: self._send_json(500, {"error": str(exc)}) + def do_DELETE(self) -> None: + if not self.path.startswith("/api/chats/"): + self.send_error(404) + return + + chat_id = self.path.rsplit("/", 1)[-1] + store = read_store() + store["chats"] = [item for item in store.get("chats", []) if item["id"] != chat_id] + write_store(store) + self._send_json(200, {"ok": True}) + + def _save_chat(self, chat_id: str | None, messages: list[dict]) -> dict: + store = read_store() + chats = store.setdefault("chats", []) + chat = next((item for item in chats if item["id"] == chat_id), None) + + if not chat: + chat = { + "id": str(uuid.uuid4()), + "createdAt": now_iso(), + "title": chat_title(messages), + "messages": [], + } + chats.insert(0, chat) + + chat["title"] = chat_title(messages) + chat["messages"] = messages + chat["updatedAt"] = now_iso() + chats.sort(key=lambda item: item.get("updatedAt", ""), reverse=True) + write_store(store) + return chat + def _send_json(self, status: int, payload: dict[str, str]) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status)