Persist chat history locally

This commit is contained in:
seo-amugae committed 2026-08-09 18:52:19 +09:00
1 parent 0ef55b1489
commit 2b2e5fcb0f
5 files changed
+165 -17

No files matched your search

+1
View File
@@ -2,3 +2,4 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.env .env
data/*.json
+9 -1
View File
@@ -1,6 +1,6 @@
# llm_web # llm_web
Local LLM chat web page powered by Ollama. Local LLM chat web page powered by Ollama and `qwen2.5:7b`.
## Run ## Run
@@ -15,3 +15,11 @@ Open:
```text ```text
http://127.0.0.1:8787 http://127.0.0.1:8787
``` ```
LAN access uses the notebook IP:
```text
http://<notebook-ip>:8787
```
Chat history is stored locally in `data/chats.json`. The `data/*.json` files are ignored by Git.
+57 -5
View File
@@ -3,7 +3,10 @@ const promptInput = document.querySelector("#prompt");
const messagesEl = document.querySelector("#messages"); const messagesEl = document.querySelector("#messages");
const sendButton = document.querySelector("#sendButton"); const sendButton = document.querySelector("#sendButton");
const newChatButton = document.querySelector("#newChat"); const newChatButton = document.querySelector("#newChat");
const historyEl = document.querySelector("#history");
const chatTitleEl = document.querySelector("#chatTitle");
let activeChatId = null;
let messages = []; let messages = [];
function addMessage(role, content) { function addMessage(role, content) {
@@ -24,10 +27,53 @@ function addMessage(role, content) {
return bubble; return bubble;
} }
function resetChat() { function renderMessages() {
messages = [];
messagesEl.innerHTML = ""; 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(); promptInput.focus();
} }
@@ -65,7 +111,7 @@ form.addEventListener("submit", async (event) => {
const response = await fetch("/api/chat", { const response = await fetch("/api/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }), body: JSON.stringify({ chatId: activeChatId, messages }),
}); });
const data = await response.json(); const data = await response.json();
@@ -74,7 +120,10 @@ form.addEventListener("submit", async (event) => {
} }
pending.textContent = data.reply; 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) { } catch (error) {
pending.textContent = `오류: ${error.message}`; pending.textContent = `오류: ${error.message}`;
} finally { } finally {
@@ -82,3 +131,6 @@ form.addEventListener("submit", async (event) => {
promptInput.focus(); promptInput.focus();
} }
}); });
renderMessages();
loadHistory();
+3 -10
View File
@@ -9,26 +9,19 @@
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
<button class="new-chat" id="newChat" type="button">+ 새 대화</button> <button class="new-chat" id="newChat" type="button">+ 새 대화</button>
<div class="history"> <div class="history" id="history"></div>
<button class="history-item active" type="button">로컬 LLM</button>
</div>
<div class="model">qwen2.5:7b</div> <div class="model">qwen2.5:7b</div>
</aside> </aside>
<main class="chat-shell"> <main class="chat-shell">
<header class="topbar"> <header class="topbar">
<div> <div>
<strong>Local LLM</strong> <strong id="chatTitle">Local LLM</strong>
<span>Ollama on this notebook</span> <span>Ollama on this notebook</span>
</div> </div>
</header> </header>
<section class="messages" id="messages" aria-live="polite"> <section class="messages" id="messages" aria-live="polite"></section>
<article class="message assistant">
<div class="avatar">L</div>
<div class="bubble">안녕하세요. 이 노트북에서 실행되는 로컬 LLM입니다.</div>
</article>
</section>
<form class="composer" id="chatForm"> <form class="composer" id="chatForm">
<textarea id="prompt" rows="1" placeholder="메시지를 입력하세요" autofocus></textarea> <textarea id="prompt" rows="1" placeholder="메시지를 입력하세요" autofocus></textarea>
+95 -1
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import json import json
import socket import socket
import uuid
from datetime import datetime, timezone
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@@ -9,15 +11,71 @@ import ollama
ROOT = Path(__file__).parent ROOT = Path(__file__).parent
DATA_DIR = ROOT / "data"
CHATS_FILE = DATA_DIR / "chats.json"
MODEL = "qwen2.5:7b" MODEL = "qwen2.5:7b"
HOST = "0.0.0.0" HOST = "0.0.0.0"
PORT = 8787 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): class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **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: def do_POST(self) -> None:
if self.path != "/api/chat": if self.path != "/api/chat":
self.send_error(404) self.send_error(404)
@@ -25,6 +83,7 @@ class Handler(SimpleHTTPRequestHandler):
length = int(self.headers.get("content-length", 0)) length = int(self.headers.get("content-length", 0))
data = json.loads(self.rfile.read(length) or b"{}") data = json.loads(self.rfile.read(length) or b"{}")
chat_id = data.get("chatId")
messages = data.get("messages", []) messages = data.get("messages", [])
try: try:
@@ -38,11 +97,46 @@ class Handler(SimpleHTTPRequestHandler):
*messages, *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) self._send_json(200, payload)
except Exception as exc: except Exception as exc:
self._send_json(500, {"error": str(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: def _send_json(self, status: int, payload: dict[str, str]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)