Persist chat history locally
This commit is contained in:
1 parent
0ef55b1489
commit
2b2e5fcb0f
5 files changed
+165
-17
No files matched your search
@@ -2,3 +2,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
data/*.json
|
||||
@@ -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://<notebook-ip>:8787
|
||||
```
|
||||
|
||||
Chat history is stored locally in `data/chats.json`. The `data/*.json` files are ignored by Git.
|
||||
@@ -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();
|
||||
+3
-10
@@ -9,26 +9,19 @@
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<button class="new-chat" id="newChat" type="button">+ 새 대화</button>
|
||||
<div class="history">
|
||||
<button class="history-item active" type="button">로컬 LLM</button>
|
||||
</div>
|
||||
<div class="history" id="history"></div>
|
||||
<div class="model">qwen2.5:7b</div>
|
||||
</aside>
|
||||
|
||||
<main class="chat-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<strong>Local LLM</strong>
|
||||
<strong id="chatTitle">Local LLM</strong>
|
||||
<span>Ollama on this notebook</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="messages" id="messages" aria-live="polite">
|
||||
<article class="message assistant">
|
||||
<div class="avatar">L</div>
|
||||
<div class="bubble">안녕하세요. 이 노트북에서 실행되는 로컬 LLM입니다.</div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="messages" id="messages" aria-live="polite"></section>
|
||||
|
||||
<form class="composer" id="chatForm">
|
||||
<textarea id="prompt" rows="1" placeholder="메시지를 입력하세요" autofocus></textarea>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user