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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
|
data/*.json
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user