commit afa8f3780ffd2410fb365d00df3d8438641cba60 Author: seo-amugae Date: Sun Aug 9 18:18:59 2026 +0900 Initial local LLM web app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a98520d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e4ab6e --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# llm_web + +Local LLM chat web page powered by Ollama. + +## Run + +```powershell +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +.\.venv\Scripts\python.exe .\server.py +``` + +Open: + +```text +http://127.0.0.1:8787 +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..67d905c --- /dev/null +++ b/app.js @@ -0,0 +1,84 @@ +const form = document.querySelector("#chatForm"); +const promptInput = document.querySelector("#prompt"); +const messagesEl = document.querySelector("#messages"); +const sendButton = document.querySelector("#sendButton"); +const newChatButton = document.querySelector("#newChat"); + +let messages = []; + +function addMessage(role, content) { + const article = document.createElement("article"); + article.className = `message ${role}`; + + const avatar = document.createElement("div"); + avatar.className = "avatar"; + avatar.textContent = role === "user" ? "U" : "L"; + + const bubble = document.createElement("div"); + bubble.className = "bubble"; + bubble.textContent = content; + + article.append(avatar, bubble); + messagesEl.append(article); + messagesEl.scrollTop = messagesEl.scrollHeight; + return bubble; +} + +function resetChat() { + messages = []; + messagesEl.innerHTML = ""; + addMessage("assistant", "새 대화를 시작합니다."); + promptInput.focus(); +} + +promptInput.addEventListener("input", () => { + promptInput.style.height = "auto"; + promptInput.style.height = `${promptInput.scrollHeight}px`; +}); + +promptInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + form.requestSubmit(); + } +}); + +newChatButton.addEventListener("click", resetChat); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + + const content = promptInput.value.trim(); + if (!content || sendButton.disabled) { + return; + } + + promptInput.value = ""; + promptInput.style.height = "auto"; + addMessage("user", content); + messages.push({ role: "user", content }); + + sendButton.disabled = true; + const pending = addMessage("assistant", "생각 중..."); + + try { + const response = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "응답 생성 실패"); + } + + pending.textContent = data.reply; + messages.push({ role: "assistant", content: data.reply }); + } catch (error) { + pending.textContent = `오류: ${error.message}`; + } finally { + sendButton.disabled = false; + promptInput.focus(); + } +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..a788e47 --- /dev/null +++ b/index.html @@ -0,0 +1,41 @@ + + + + + + Local LLM + + + + + +
+
+
+ Local LLM + Ollama on this notebook +
+
+ +
+
+
L
+
안녕하세요. 이 노트북에서 실행되는 로컬 LLM입니다.
+
+
+ +
+ + +
+
+ + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..81a2edc --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ollama==0.6.2 diff --git a/server.py b/server.py new file mode 100644 index 0000000..92dfe31 --- /dev/null +++ b/server.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import ollama + + +ROOT = Path(__file__).parent +MODEL = "qwen2.5:3b" + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(ROOT), **kwargs) + + def do_POST(self) -> None: + if self.path != "/api/chat": + self.send_error(404) + return + + length = int(self.headers.get("content-length", 0)) + data = json.loads(self.rfile.read(length) or b"{}") + messages = data.get("messages", []) + + try: + response = ollama.chat( + model=MODEL, + messages=[ + { + "role": "system", + "content": "You are a concise local assistant. Reply in Korean unless the user asks otherwise.", + }, + *messages, + ], + ) + payload = {"reply": response["message"]["content"]} + self._send_json(200, payload) + except Exception as exc: + self._send_json(500, {"error": str(exc)}) + + 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) + self.send_header("content-type", "application/json; charset=utf-8") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main() -> None: + server = ThreadingHTTPServer(("127.0.0.1", 8787), Handler) + print("LLM web is running at http://127.0.0.1:8787") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..bced13f --- /dev/null +++ b/styles.css @@ -0,0 +1,189 @@ +:root { + --bg: #f7f7f4; + --panel: #ffffff; + --sidebar: #171717; + --sidebar-soft: #242424; + --text: #202123; + --muted: #6f6f6f; + --line: #deded8; + --accent: #10a37f; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: grid; + grid-template-columns: 260px 1fr; + color: var(--text); + background: var(--bg); + font-family: Arial, "Malgun Gothic", sans-serif; +} + +.sidebar { + min-height: 100vh; + padding: 12px; + color: white; + background: var(--sidebar); + display: flex; + flex-direction: column; + gap: 12px; +} + +.new-chat, +.history-item { + width: 100%; + min-height: 42px; + border: 1px solid #3a3a3a; + border-radius: 8px; + color: white; + background: transparent; + text-align: left; + padding: 0 12px; + font-size: 14px; + cursor: pointer; +} + +.history { + flex: 1; +} + +.history-item.active { + background: var(--sidebar-soft); +} + +.model { + padding: 12px; + border-top: 1px solid #333; + color: #cfcfcf; + font-size: 13px; +} + +.chat-shell { + min-width: 0; + min-height: 100vh; + display: grid; + grid-template-rows: 58px 1fr auto; +} + +.topbar { + display: flex; + align-items: center; + padding: 0 24px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.8); +} + +.topbar div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.topbar span { + color: var(--muted); + font-size: 13px; +} + +.messages { + width: min(860px, 100%); + margin: 0 auto; + padding: 28px 20px 120px; + overflow: auto; +} + +.message { + display: grid; + grid-template-columns: 34px 1fr; + gap: 14px; + margin: 0 0 24px; +} + +.avatar { + width: 34px; + height: 34px; + border-radius: 6px; + display: grid; + place-items: center; + color: white; + background: var(--accent); + font-weight: 700; +} + +.message.user .avatar { + background: #565869; +} + +.bubble { + max-width: 100%; + padding: 6px 0; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.composer { + position: fixed; + left: 260px; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + width: min(860px, calc(100% - 300px)); + margin: 0 auto; + padding: 18px 20px 22px; + background: linear-gradient(to top, var(--bg) 80%, rgba(247, 247, 244, 0)); +} + +textarea { + min-height: 52px; + max-height: 180px; + resize: none; + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px 16px; + font: inherit; + line-height: 1.4; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.08); +} + +textarea:focus { + border-color: var(--accent); + outline: none; +} + +#sendButton { + align-self: end; + min-width: 72px; + height: 52px; + border: 0; + border-radius: 8px; + color: white; + background: var(--accent); + font-weight: 700; + cursor: pointer; +} + +#sendButton:disabled { + cursor: wait; + opacity: 0.6; +} + +@media (max-width: 720px) { + body { + grid-template-columns: 1fr; + } + + .sidebar { + display: none; + } + + .composer { + left: 0; + width: 100%; + } +}