From 16a8bfebde12e2e3fa50b2a1384091158fdabe91 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 24 Jun 2026 00:19:26 +0900 Subject: 機能追加(セッション、左ペインでのセッション管理、ファイル取得ツール、pdfテキスト化ツール) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + pyproject.toml | 2 + requirements.txt | 2 + src/ollama_web/app.py | 36 +++++- src/ollama_web/config.py | 8 ++ src/ollama_web/routes/chat.py | 129 +++++++++++++++++-- src/ollama_web/routes/sessions.py | 120 +++++++++++++++++ src/ollama_web/sessions.py | 248 ++++++++++++++++++++++++++++++++++++ src/ollama_web/static/app.js | 231 +++++++++++++++++++++++++++++++-- src/ollama_web/static/style.css | 164 +++++++++++++++++++++++- src/ollama_web/templates/index.html | 72 +++++++---- src/ollama_web/tools/fetch.py | 204 +++++++++++++++++++++++++++++ src/ollama_web/tools/pdf.py | 88 +++++++++++++ src/ollama_web/tools/registry.py | 4 + 14 files changed, 1260 insertions(+), 53 deletions(-) create mode 100644 src/ollama_web/routes/sessions.py create mode 100644 src/ollama_web/sessions.py create mode 100644 src/ollama_web/tools/fetch.py create mode 100644 src/ollama_web/tools/pdf.py diff --git a/.gitignore b/.gitignore index 6135655..c97b8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -168,4 +168,9 @@ models/ *.gguf *.bin +# Session data +data/ + +# other +.ruff_cache *.lock \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4a8ba25..a7cc88d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ dependencies = [ "httpx>=0.27", "beautifulsoup4>=0.0.1", "jinja2>=3.1", + "pypdf>=4.0", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index db09b9c..64f028c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ uvicorn>=0.30 httpx>=0.27 beautifulsoup4>=0.0.1 jinja2>=3.1 +pypdf>=4.0 +python-multipart>=0.0.9 diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py index d37557d..7c002b4 100644 --- a/src/ollama_web/app.py +++ b/src/ollama_web/app.py @@ -14,6 +14,8 @@ from .config import settings from .routes import chat as chat_route from .routes import models as models_route from .routes import pages as pages_route +from .routes import sessions as sessions_route +from .sessions import SessionStore _STATIC_DIR = Path(__file__).resolve().parent / "static" @@ -25,6 +27,37 @@ def create_app() -> Starlette: Route("/api/chat", chat_route.chat, methods=["POST"], name="chat"), Route("/api/models", models_route.get_models, methods=["GET"], name="models"), Route("/api/health", models_route.health, methods=["GET"], name="health"), + Route("/api/sessions", sessions_route.list_sessions, methods=["GET"], name="list_sessions"), + Route( + "/api/sessions", + sessions_route.create_session, + methods=["POST"], + name="create_session", + ), + Route( + "/api/sessions/{session_id}", + sessions_route.get_session, + methods=["GET"], + name="get_session", + ), + Route( + "/api/sessions/{session_id}", + sessions_route.delete_session, + methods=["DELETE"], + name="delete_session", + ), + Route( + "/api/sessions/{session_id}/files", + sessions_route.upload_file, + methods=["POST"], + name="upload_file", + ), + Route( + "/api/sessions/{session_id}/files/{file_id}", + sessions_route.delete_file, + methods=["DELETE"], + name="delete_file", + ), Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"), ] @@ -39,7 +72,8 @@ def create_app() -> Starlette: app = Starlette(routes=routes, middleware=middleware) app.state.settings = settings - app.state.tool_names = ["web_search", "scrape_url"] + app.state.session_store = SessionStore(settings.data_dir) + app.state.tool_names = ["web_search", "scrape_url", "search_and_fetch", "pdf_to_text"] return app diff --git a/src/ollama_web/config.py b/src/ollama_web/config.py index 4f39d17..44947dd 100644 --- a/src/ollama_web/config.py +++ b/src/ollama_web/config.py @@ -16,10 +16,18 @@ class Settings: ) host: str = field(default_factory=lambda: os.environ.get("OLLAMA_WEB_HOST", "0.0.0.0")) port: int = field(default_factory=lambda: int(os.environ.get("OLLAMA_WEB_PORT", "8000"))) + data_dir: str = field( + default_factory=lambda: os.environ.get("OLLAMA_WEB_DATA_DIR", "data") + ) + max_upload_mb: int = field( + default_factory=lambda: int(os.environ.get("OLLAMA_WEB_MAX_UPLOAD_MB", "20")) + ) ollama_timeout: float = 120.0 scrape_timeout: float = 15.0 scrape_max_chars: int = 4000 search_max_results: int = 5 + fetch_max_results: int = 3 + fetch_max_chars: int = 8000 settings = Settings() diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index 2612279..8b6c302 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -2,7 +2,10 @@ from __future__ import annotations +import functools +import inspect import json +import logging from collections.abc import AsyncIterator from typing import Any @@ -10,6 +13,12 @@ from ollama import Message from starlette.requests import Request from starlette.responses import StreamingResponse +from ..sessions import SessionStore +from ..tools.fetch import pop_fetched_files +from ..tools.registry import ToolRegistry, default_registry + +logger = logging.getLogger("ollama_web.chat") + def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]: """Convert raw JSON message dicts into ``ollama.Message`` objects.""" @@ -28,27 +37,127 @@ def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]: return out -async def _event_stream(request: Request, payload: dict[str, Any]) -> AsyncIterator[bytes]: +def _session_aware_registry(session_id: str | None) -> ToolRegistry: + """Return a registry whose search_and_fetch receives the session id.""" + reg = default_registry() + + original = reg.get("search_and_fetch") + if original is not None and session_id is not None: + + @functools.wraps(original) + def wrapped(*args: Any, **kwargs: Any) -> Any: + sig = inspect.signature(original) + bound = sig.bind(*args, **kwargs) + bound.arguments["session_id"] = session_id + return original(*bound.args, **bound.kwargs) + + reg._tools["search_and_fetch"] = wrapped # noqa: SLF001 + + return reg + + +def _attachment_context(store: SessionStore, session_id: str, file_ids: list[str]) -> str: + parts: list[str] = [] + for file_id in file_ids: + chat_file = store.get_file(session_id, file_id) + if chat_file is None: + continue + text = chat_file.text or "" + parts.append( + f"\n{text}\n" + ) + if not parts: + return "" + return "\n\n".join(["以下はユーザーが添付したファイルの内容です:", *parts]) + + +async def _event_stream( + request: Request, + payload: dict[str, Any], + session_id: str | None, +) -> AsyncIterator[bytes]: """Yield SSE events from the tool-augmented chat loop.""" model = payload.get("model") or request.app.state.settings.default_model - raw_messages = payload.get("messages", []) think = payload.get("think") + store: SessionStore = request.app.state.session_store + file_ids: list[str] = list(payload.get("file_ids", [])) + + session_messages: list[Message] = [] + if session_id: + session = store.get(session_id) + if session is not None: + session_messages = _parse_messages(session.get("messages", [])) + + user_content = "" + raw_messages = payload.get("messages", []) + if raw_messages: + user_content = str(raw_messages[-1].get("content", "") or "") + + attachment_ctx = "" + if session_id and file_ids: + attachment_ctx = _attachment_context(store, session_id, file_ids) + + if attachment_ctx and user_content: + # Inject attachment context into the latest user message. + augmented = f"{user_content}\n\n{attachment_ctx}" + if raw_messages: + raw_messages[-1]["content"] = augmented + elif attachment_ctx: + raw_messages.append({"role": "user", "content": attachment_ctx}) + messages = _parse_messages(raw_messages) + if session_messages: + messages = session_messages + messages + host = request.app.state.settings.ollama_host from ollama_web import llm # local import to avoid circular at module load - async for event in llm.astream_chat_with_tools(messages, model, think=think, host=host): + registry = _session_aware_registry(session_id) + + assistant_text = "" + finished = False + async for event in llm.astream_chat_with_tools(messages, model, think=think, host=host, registry=registry): + if event.get("type") == "delta": + assistant_text += event.get("content", "") + elif event.get("type") == "done": + finished = True data = json.dumps(event, ensure_ascii=False) yield f"data: {data}\n\n".encode() + # Persist user and assistant messages to the session. + if session_id and user_content: + store.add_message( + session_id=session_id, + role="user", + content=user_content, + attachments=file_ids, + ) + if assistant_text: + store.add_message( + session_id=session_id, + role="assistant", + content=assistant_text, + ) + + # Persist fetched files from tools like search_and_fetch. + if session_id: + fetched = pop_fetched_files(session_id) + for f in fetched: + store.add_file( + session_id=session_id, + name=f["name"], + data=f["data"], + mime=f["mime"], + text=f["text"], + source="fetch", + ) + async def chat(request: Request) -> StreamingResponse: """POST /api/chat: stream chat events as Server-Sent Events.""" - import logging - - logger = logging.getLogger("ollama_web.chat") try: body = await request.body() payload = json.loads(body.decode("utf-8")) @@ -59,14 +168,16 @@ async def chat(request: Request) -> StreamingResponse: _single_event(data), media_type="text/event-stream", ) + + session_id = payload.get("session_id") msg_count = len(payload.get("messages", [])) - logger.info("chat request model=%s messages=%d", payload.get("model"), msg_count) + logger.info("chat request model=%s session=%s messages=%d", payload.get("model"), session_id, msg_count) return StreamingResponse( - _event_stream(request, payload), + _event_stream(request, payload, session_id), media_type="text/event-stream", ) async def _single_event(data: str) -> AsyncIterator[bytes]: """Yield a single SSE event and finish.""" - yield f"data: {data}\n\n".encode() + yield f"data: {data}\n\n".encode() \ No newline at end of file diff --git a/src/ollama_web/routes/sessions.py b/src/ollama_web/routes/sessions.py new file mode 100644 index 0000000..8980960 --- /dev/null +++ b/src/ollama_web/routes/sessions.py @@ -0,0 +1,120 @@ +"""Session management API routes.""" + +from __future__ import annotations + +import logging +from typing import Any + +from starlette.requests import Request +from starlette.responses import JSONResponse + +from ..sessions import SessionStore +from ..tools.pdf import extract_pdf_text + +logger = logging.getLogger("ollama_web.sessions") + + +def _get_store(request: Request) -> SessionStore: + return request.app.state.session_store + + +async def list_sessions(request: Request) -> JSONResponse: + store = _get_store(request) + return JSONResponse({"sessions": store.list_sessions()}) + + +async def create_session(request: Request) -> JSONResponse: + store = _get_store(request) + body = await request.json() if await request.body() else {} + title = body.get("title") + session = store.create(title=title) + return JSONResponse(session) + + +async def get_session(request: Request) -> JSONResponse: + store = _get_store(request) + session_id = request.path_params["session_id"] + session = store.get(session_id) + if session is None: + return JSONResponse({"error": "Session not found"}, status_code=404) + return JSONResponse(session) + + +async def delete_session(request: Request) -> JSONResponse: + store = _get_store(request) + session_id = request.path_params["session_id"] + ok = store.delete(session_id) + if not ok: + return JSONResponse({"error": "Session not found"}, status_code=404) + return JSONResponse({"deleted": True}) + + +async def upload_file(request: Request) -> JSONResponse: + store = _get_store(request) + session_id = request.path_params["session_id"] + session = store.get(session_id) + if session is None: + return JSONResponse({"error": "Session not found"}, status_code=404) + + max_size = request.app.state.settings.max_upload_mb * 1024 * 1024 + try: + form = await request.form() + except Exception as exc: # noqa: BLE001 + logger.error("failed to parse multipart form: %s", exc) + return JSONResponse({"error": f"Invalid form data: {exc}"}, status_code=400) + + files = form.getlist("files") if hasattr(form, "getlist") else [form.get("files")] + results: list[dict[str, Any]] = [] + for upload in files: + if upload is None: + continue + try: + data = await upload.read() + except Exception as exc: # noqa: BLE001 + results.append({"name": getattr(upload, "filename", "unknown"), "error": str(exc)}) + continue + if len(data) > max_size: + results.append( + { + "name": getattr(upload, "filename", "unknown"), + "error": f"File exceeds {request.app.state.settings.max_upload_mb} MB limit", + } + ) + continue + + name = getattr(upload, "filename", "uploaded-file") or "uploaded-file" + content_type = getattr(upload, "content_type", None) or "application/octet-stream" + text = "" + if content_type == "application/pdf" or name.lower().endswith(".pdf"): + text = extract_pdf_text(data) + else: + # Try decode as text for text-like uploads. + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + try: + text = data.decode("cp932") + except UnicodeDecodeError: + text = data.decode("utf-8", errors="replace") + + chat_file = store.add_file( + session_id=session_id, + name=name, + data=data, + mime=content_type, + text=text, + source="upload", + ) + results.append(chat_file.to_dict()) + + return JSONResponse({"files": results}) + + +async def delete_file(request: Request) -> JSONResponse: + store = _get_store(request) + session_id = request.path_params["session_id"] + file_id = request.path_params["file_id"] + ok = store.remove_file(session_id, file_id) + if not ok: + return JSONResponse({"error": "File not found"}, status_code=404) + return JSONResponse({"deleted": True}) \ No newline at end of file diff --git a/src/ollama_web/sessions.py b/src/ollama_web/sessions.py new file mode 100644 index 0000000..bfe659c --- /dev/null +++ b/src/ollama_web/sessions.py @@ -0,0 +1,248 @@ +"""Persistent session storage for ollama-web.""" + +from __future__ import annotations + +import json +import shutil +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .config import settings + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class ChatFile: + id: str + name: str + path: str + mime: str + size: int + text: str + source: str # "upload" | "fetch" + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "path": self.path, + "mime": self.mime, + "size": self.size, + "text": self.text, + "source": self.source, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ChatFile": + return cls( + id=data["id"], + name=data["name"], + path=data["path"], + mime=data.get("mime", "application/octet-stream"), + size=data.get("size", 0), + text=data.get("text", ""), + source=data.get("source", "upload"), + ) + + +@dataclass +class ChatMessage: + role: str + content: str + attachments: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "role": self.role, + "content": self.content, + "attachments": list(self.attachments), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ChatMessage": + return cls( + role=data.get("role", "user"), + content=data.get("content", ""), + attachments=list(data.get("attachments", [])), + ) + + +class SessionStore: + """Manage chat sessions as JSON files under the configured data directory.""" + + def __init__(self, data_dir: str | Path | None = None) -> None: + self.data_dir = Path(data_dir or settings.data_dir).resolve() + self.sessions_dir = self.data_dir / "sessions" + self.sessions_dir.mkdir(parents=True, exist_ok=True) + + def _session_dir(self, session_id: str) -> Path: + return self.sessions_dir / session_id + + def _session_file(self, session_id: str) -> Path: + return self._session_dir(session_id) / "session.json" + + def _files_dir(self, session_id: str) -> Path: + files_dir = self._session_dir(session_id) / "files" + files_dir.mkdir(parents=True, exist_ok=True) + return files_dir + + def list_sessions(self) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + if not self.sessions_dir.exists(): + return out + for entry in sorted(self.sessions_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + if not entry.is_dir(): + continue + session_file = entry / "session.json" + if not session_file.exists(): + continue + try: + data = json.loads(session_file.read_text(encoding="utf-8")) + out.append( + { + "id": data["id"], + "title": data.get("title", "Untitled"), + "updated_at": data.get("updated_at", data.get("created_at", "")), + } + ) + except Exception: # noqa: BLE001 + continue + return out + + def create(self, title: str | None = None) -> dict[str, Any]: + session_id = uuid.uuid4().hex + now = _now() + session = { + "id": session_id, + "title": title or "新しいセッション", + "created_at": now, + "updated_at": now, + "messages": [], + "files": [], + } + self._session_dir(session_id).mkdir(parents=True, exist_ok=True) + self._save(session_id, session) + return session + + def get(self, session_id: str) -> dict[str, Any] | None: + session_file = self._session_file(session_id) + if not session_file.exists(): + return None + try: + return json.loads(session_file.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return None + + def save(self, session: dict[str, Any]) -> None: + session["updated_at"] = _now() + self._save(session["id"], session) + + def _save(self, session_id: str, session: dict[str, Any]) -> None: + session_file = self._session_file(session_id) + session_file.parent.mkdir(parents=True, exist_ok=True) + session_file.write_text(json.dumps(session, ensure_ascii=False, indent=2), encoding="utf-8") + + def delete(self, session_id: str) -> bool: + session_dir = self._session_dir(session_id) + if not session_dir.exists(): + return False + try: + shutil.rmtree(session_dir) + return True + except Exception: # noqa: BLE001 + return False + + def add_message( + self, + session_id: str, + role: str, + content: str, + attachments: list[str] | None = None, + ) -> dict[str, Any] | None: + session = self.get(session_id) + if session is None: + return None + session["messages"].append( + ChatMessage( + role=role, + content=content, + attachments=list(attachments or []), + ).to_dict() + ) + if role == "user" and len(session["messages"]) <= 2: + first_user = next( + (m for m in session["messages"] if m["role"] == "user"), + None, + ) + if first_user: + title = first_user["content"].strip().replace("\n", " ")[:40] + session["title"] = title + ("…" if len(first_user["content"]) > 40 else "") + self.save(session) + return session + + def add_file( + self, + session_id: str, + name: str, + data: bytes, + mime: str, + text: str, + source: str = "upload", + ) -> ChatFile: + self._session_dir(session_id).mkdir(parents=True, exist_ok=True) + file_id = uuid.uuid4().hex + ext = Path(name).suffix + stored_name = f"{file_id}{ext}" + file_path = self._files_dir(session_id) / stored_name + file_path.write_bytes(data) + rel_path = str((self._session_dir(session_id) / "files" / stored_name).relative_to(self.data_dir)) + chat_file = ChatFile( + id=file_id, + name=name, + path=rel_path, + mime=mime, + size=len(data), + text=text, + source=source, + ) + session = self.get(session_id) + if session is None: + session = self.create() + session_id = session["id"] + session["files"].append(chat_file.to_dict()) + self.save(session) + return chat_file + + def remove_file(self, session_id: str, file_id: str) -> bool: + session = self.get(session_id) + if session is None: + return False + files = session.get("files", []) + target = next((f for f in files if f["id"] == file_id), None) + if target is None: + return False + files.remove(target) + session["files"] = files + try: + abs_path = self.data_dir / target["path"] + if abs_path.exists(): + abs_path.unlink() + except Exception: # noqa: BLE001 + pass + self.save(session) + return True + + def get_file(self, session_id: str, file_id: str) -> ChatFile | None: + session = self.get(session_id) + if session is None: + return None + for f in session.get("files", []): + if f["id"] == file_id: + return ChatFile.from_dict(f) + return None \ No newline at end of file diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 845a71f..339030a 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -8,12 +8,17 @@ const modelSelect = document.getElementById("model-select"); const thinkToggle = document.getElementById("think-toggle"); const clearBtn = document.getElementById("clear-btn"); const cancelBtn = document.getElementById("cancel-btn"); +const fileInput = document.getElementById("file-input"); +const attachmentsEl = document.getElementById("attachments"); +const sessionListEl = document.getElementById("session-list"); +const newSessionBtn = document.getElementById("new-session-btn"); -let messages = []; +let currentSessionId = null; +let currentSession = null; +let pendingFiles = []; let abortController = null; function simpleMarkdownToHtml(text) { - // Render plain text safely as HTML without external dependencies. const el = document.createElement("div"); el.setAttribute("style", "white-space:pre-wrap"); el.textContent = text; @@ -115,19 +120,211 @@ function parseSseEvent(chunk) { return events; } +async function loadSessions() { + try { + const res = await fetch("/api/sessions"); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + renderSessionList(data.sessions || []); + } catch (err) { + console.error("failed to load sessions", err); + renderSessionList([]); + } +} + +function renderSessionList(sessions) { + sessionListEl.innerHTML = ""; + if (!sessions.length) { + const li = document.createElement("li"); + li.className = "empty"; + li.textContent = "セッションがありません"; + sessionListEl.appendChild(li); + return; + } + for (const s of sessions) { + const li = document.createElement("li"); + li.dataset.id = s.id; + if (s.id === currentSessionId) li.classList.add("active"); + const title = document.createElement("span"); + title.className = "title"; + title.textContent = s.title || s.id; + const del = document.createElement("button"); + del.className = "delete"; + del.textContent = "削除"; + del.title = "セッションを削除"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + deleteSession(s.id); + }); + li.append(title, del); + li.addEventListener("click", () => selectSession(s.id)); + sessionListEl.appendChild(li); + } +} + +async function createSession() { + try { + const res = await fetch("/api/sessions", { method: "POST" }); + if (!res.ok) throw new Error(res.statusText); + const session = await res.json(); + await loadSessions(); + await selectSession(session.id); + } catch (err) { + console.error("failed to create session", err); + alert("セッションの作成に失敗しました"); + } +} + +async function deleteSession(id) { + if (!confirm("このセッションを削除しますか?")) return; + try { + const res = await fetch(`/api/sessions/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(res.statusText); + if (currentSessionId === id) { + currentSessionId = null; + currentSession = null; + chatEl.innerHTML = ""; + pendingFiles = []; + renderAttachments(); + } + await loadSessions(); + } catch (err) { + console.error("failed to delete session", err); + alert("セッションの削除に失敗しました"); + } +} + +async function selectSession(id) { + try { + const res = await fetch(`/api/sessions/${id}`); + if (!res.ok) throw new Error(res.statusText); + const session = await res.json(); + currentSessionId = session.id; + currentSession = session; + renderSession(); + renderSessionList((await (await fetch("/api/sessions")).json()).sessions || []); + } catch (err) { + console.error("failed to select session", err); + } +} + +function renderSession() { + chatEl.innerHTML = ""; + if (!currentSession) return; + const msgs = currentSession.messages || []; + for (const m of msgs) { + if (m.role === "user") { + const body = addMessage("user").body; + body.innerHTML = renderMarkdown(m.content); + highlightIn(body); + if (m.attachments && m.attachments.length) { + const info = document.createElement("div"); + info.style.fontSize = "12px"; + info.style.color = "var(--muted)"; + info.style.marginTop = "6px"; + const names = m.attachments + .map((fid) => { + const f = (currentSession.files || []).find((x) => x.id === fid); + return f ? f.name : fid; + }) + .join(", "); + info.textContent = `添付: ${names}`; + body.appendChild(info); + } + } else if (m.role === "assistant") { + const body = addMessage("assistant").body; + body.innerHTML = renderMarkdown(m.content); + highlightIn(body); + } + } +} + +function renderAttachments() { + attachmentsEl.innerHTML = ""; + for (const f of pendingFiles) { + const chip = document.createElement("span"); + chip.className = "attachment-chip"; + chip.textContent = f.name; + const remove = document.createElement("span"); + remove.className = "remove"; + remove.textContent = "×"; + remove.addEventListener("click", () => { + pendingFiles = pendingFiles.filter((x) => x !== f); + renderAttachments(); + }); + chip.appendChild(remove); + attachmentsEl.appendChild(chip); + } +} + +async function uploadFiles(files) { + if (!currentSessionId) { + await createSession(); + } + if (!currentSessionId) return; + + const form = new FormData(); + for (const file of files) { + form.append("files", file); + } + try { + const res = await fetch(`/api/sessions/${currentSessionId}/files`, { + method: "POST", + body: form, + }); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + for (const f of data.files || []) { + if (f.error) { + alert(`${f.name}: ${f.error}`); + continue; + } + pendingFiles.push({ id: f.id, name: f.name }); + } + renderAttachments(); + await selectSession(currentSessionId); + } catch (err) { + console.error("failed to upload files", err); + alert("ファイルのアップロードに失敗しました"); + } +} + +fileInput.addEventListener("change", () => { + if (fileInput.files && fileInput.files.length) { + uploadFiles(fileInput.files); + fileInput.value = ""; + } +}); + +newSessionBtn.addEventListener("click", createSession); + async function send() { const text = inputEl.value.trim(); - if (!text) return; - inputEl.value = ""; + if (!text && !pendingFiles.length) return; + + if (!currentSessionId) { + await createSession(); + } + if (!currentSessionId) return; + + if (text) inputEl.value = ""; sendBtn.disabled = true; cancelBtn.disabled = false; abortController = new AbortController(); - messages.push({ role: "user", content: text }); + const fileIds = pendingFiles.map((f) => f.id); const userBody = addMessage("user").body; - userBody.innerHTML = renderMarkdown(text); + let displayContent = text; + if (pendingFiles.length) { + const names = pendingFiles.map((f) => f.name).join(", "); + displayContent += (text ? "\n\n" : "") + `添付ファイル: ${names}`; + } + userBody.innerHTML = renderMarkdown(displayContent); highlightIn(userBody); + pendingFiles = []; + renderAttachments(); + const assistantMsg = addMessage("assistant"); const assistantBody = assistantMsg.body; const assistantExtras = assistantMsg.extras; @@ -139,7 +336,14 @@ async function send() { const model = modelSelect.value; const think = thinkToggle.checked; - const requestBody = JSON.stringify({ model, messages, think }); + const requestBody = JSON.stringify({ + model, + messages: text ? [{ role: "user", content: text }] : [], + think, + session_id: currentSessionId, + file_ids: fileIds, + }); + let res; try { res = await fetch("/api/chat", { @@ -169,7 +373,6 @@ async function send() { const decoder = new TextDecoder(); let buffer = ""; - // Remove the placeholder "…" as soon as real content arrives. let placeholderCleared = false; function appendDelta(content) { @@ -188,7 +391,7 @@ async function send() { if (done) break; buffer += decoder.decode(value, { stream: true }); - const endIndex = buffer.lastIndexOf("\n\n"); + const endIndex = buffer.lastIndexOf("\\n\\n"); if (endIndex === -1) continue; const chunk = buffer.slice(0, endIndex); buffer = buffer.slice(endIndex + 2); @@ -250,13 +453,12 @@ async function send() { assistantBody.textContent = "(応答なし)"; } - if (assistantText) { - messages.push({ role: "assistant", content: assistantText }); - } abortController = null; cancelBtn.disabled = true; sendBtn.disabled = false; inputEl.focus(); + + await selectSession(currentSessionId); } formEl.addEventListener("submit", (e) => { @@ -272,7 +474,6 @@ inputEl.addEventListener("keydown", (e) => { }); clearBtn.addEventListener("click", () => { - messages = []; chatEl.innerHTML = ""; }); @@ -280,4 +481,6 @@ cancelBtn.addEventListener("click", () => { if (abortController) { abortController.abort(); } -}); \ No newline at end of file +}); + +loadSessions(); \ No newline at end of file diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css index 0be42c2..d12cf48 100644 --- a/src/ollama_web/static/style.css +++ b/src/ollama_web/static/style.css @@ -9,6 +9,7 @@ --assistant-bg: #161b22; --tool-bg: #2d1117; --think-bg: #1a2332; + --danger: #f85149; } * { box-sizing: border-box; } @@ -24,8 +25,112 @@ html, body { body { display: flex; - flex-direction: column; height: 100vh; + overflow: hidden; +} + +#sidebar { + width: 260px; + min-width: 260px; + background: var(--panel); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h2 { + font-size: 15px; + margin: 0; + color: var(--muted); +} + +.sidebar-header button { + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--accent); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 2px 8px; +} + +.sidebar-header button:hover { + border-color: var(--accent); +} + +#session-list { + list-style: none; + margin: 0; + padding: 8px; + overflow-y: auto; + flex: 1; +} + +#session-list li { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + margin-bottom: 4px; +} + +#session-list li:hover { + background: #21262d; +} + +#session-list li.active { + background: #1f6feb33; + border: 1px solid var(--accent); +} + +#session-list li .title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; +} + +#session-list li .delete { + display: none; + background: transparent; + border: none; + color: var(--danger); + cursor: pointer; + font-size: 12px; + padding: 2px 4px; +} + +#session-list li:hover .delete { + display: inline; +} + +#session-list li.empty { + color: var(--muted); + font-size: 13px; + cursor: default; +} + +#session-list li.empty:hover { + background: transparent; +} + +#main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; } header { @@ -134,14 +239,67 @@ main#chat { background: var(--think-bg); } -form#input-form { +#attachments { display: flex; - gap: 8px; + flex-wrap: wrap; + gap: 6px; + padding: 6px 16px 0; + background: var(--panel); + border-top: 1px solid var(--border); + min-height: 0; +} + +#attachments:empty { + display: none; +} + +.attachment-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 8px; + font-size: 13px; +} + +.attachment-chip .remove { + cursor: pointer; + color: var(--danger); + font-weight: 600; +} + +form#input-form { padding: 12px 16px; background: var(--panel); border-top: 1px solid var(--border); } +.input-row { + display: flex; + gap: 8px; + align-items: stretch; +} + +.attach-btn { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 20px; +} + +.attach-btn:hover { border-color: var(--accent); } + +.attach-btn input[type="file"] { + display: none; +} + textarea#input { flex: 1; resize: none; diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html index 14bdf87..eae3b6d 100644 --- a/src/ollama_web/templates/index.html +++ b/src/ollama_web/templates/index.html @@ -10,41 +10,61 @@ -
-

ollama-web

-
- - - - +
+ + -
+
+
+

ollama-web

+
+ + + + +
+
-
- - - -
+
+ +
+ +
+
+ + + + +
+
+
- + \ No newline at end of file diff --git a/src/ollama_web/tools/fetch.py b/src/ollama_web/tools/fetch.py new file mode 100644 index 0000000..62670b2 --- /dev/null +++ b/src/ollama_web/tools/fetch.py @@ -0,0 +1,204 @@ +"""Search-and-fetch tool that finds files on the web by type and returns text.""" + +from __future__ import annotations + +import base64 +import mimetypes +import re +from pathlib import Path +from typing import Any + +import httpx + +from ..config import settings +from .pdf import extract_pdf_text +from .scrape import scrape_url + + +_FetchedFile = dict[str, Any] + +# Per-session temporary storage for files fetched by search_and_fetch. +# The chat route drains this after the tool call finishes. +_fetched_files: dict[str, list[_FetchedFile]] = {} + + +_FILETYPE_SUFFIXES: dict[str, str] = { + "pdf": "pdf", + "txt": "txt", + "csv": "csv", + "md": "md", + "json": "json", + "html": "html", + "htm": "htm", + "xml": "xml", + "py": "py", + "js": "js", + "ts": "ts", +} + + +def set_session_context(session_id: str | None) -> None: + """Set the active session id so fetched files can be persisted later.""" + # No-op marker kept for future hooks; storage is keyed by session_id directly. + pass + + +def store_fetched_file(session_id: str, name: str, data: bytes, mime: str, text: str, url: str) -> None: + """Store a fetched file so the chat route can persist it to the session.""" + if session_id: + _fetched_files.setdefault(session_id, []).append( + { + "name": name, + "data": data, + "mime": mime, + "text": text, + "url": url, + } + ) + + +def pop_fetched_files(session_id: str) -> list[_FetchedFile]: + """Return and clear any files fetched for the given session.""" + return _fetched_files.pop(session_id, []) + + +def _normalize_file_type(file_type: str) -> str: + ft = file_type.strip().lower().lstrip(".") + return _FILETYPE_SUFFIXES.get(ft, ft) + + +def _filetype_query(query: str, file_type: str) -> str: + return f"{query} filetype:{file_type}" + + +def _is_matching_url(url: str, file_type: str) -> bool: + suffix = f".{file_type}" + parsed = httpx.URL(url) + path = parsed.path or "" + if path.lower().endswith(suffix): + return True + # Some CDNs embed extension after query params. + if re.search(rf"\.{re.escape(file_type)}(?:[?#]|$)", url, re.IGNORECASE): + return True + return False + + +def _guess_mime(name: str, file_type: str) -> str: + mime, _ = mimetypes.guess_type(name) + return mime or f"application/{file_type}" + + +def _fetch_bytes(url: str) -> bytes: + with httpx.Client( + timeout=settings.scrape_timeout, + follow_redirects=True, + headers={"User-Agent": "ollama-web/0.1 (+https://github.com/local)"}, + ) as client: + resp = client.get(url) + resp.raise_for_status() + return resp.content + + +def _text_from_bytes(url: str, data: bytes, file_type: str, max_chars: int) -> str: + name = Path(httpx.URL(url).path or "file").name or f"file.{file_type}" + if not name or "." not in name: + name = f"file.{file_type}" + + if file_type == "pdf": + return extract_pdf_text(data, max_chars=max_chars) + + # Decode text-like formats. + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + try: + text = data.decode("cp932") + except UnicodeDecodeError: + text = data.decode("utf-8", errors="replace") + + if file_type in {"html", "htm"}: + text = scrape_url(url, max_chars=max_chars) + + if len(text) > max_chars: + text = text[:max_chars].rsplit(" ", 1)[0] + "\n…[truncated]" + return text + + +def search_and_fetch( + query: str, + file_type: str, + max_results: int | None = None, + max_chars: int | None = None, + session_id: str | None = None, +) -> str: + """Search the web for files of a given type and return their text content. + + Args: + query: Search keywords. + file_type: Desired file extension (e.g. "pdf", "csv", "txt", "md", "json"). + max_results: Number of candidate URLs to fetch. + max_chars: Max characters per file to return. + session_id: If provided, fetched files are queued for session persistence. + + Returns: + A formatted string containing metadata and extracted text for each + successfully fetched file. + """ + if not query or not query.strip(): + return "Empty query." + + ft = _normalize_file_type(file_type) + if not ft: + return "Invalid file type." + + limit = ( + max_results + if max_results is not None and max_results > 0 + else settings.fetch_max_results + ) + per_file_chars = ( + max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars + ) + + from .search import web_search + + search_query = _filetype_query(query, ft) + results = web_search(search_query, max_results=limit * 2) + + matched: list[dict[str, str]] = [] + for item in results: + href = item.get("href", "") + if _is_matching_url(href, ft): + matched.append(item) + if len(matched) >= limit: + break + + if not matched: + return f"No {ft} files found for query: {query}" + + outputs: list[str] = [] + for idx, item in enumerate(matched, 1): + url = item.get("href", "") + title = item.get("title", "") + try: + data = _fetch_bytes(url) + except Exception as exc: # noqa: BLE001 + outputs.append(f"=== 候補 {idx} ===\nURL: {url}\nタイトル: {title}\n状態: 取得失敗 ({exc})\n") + continue + + text = _text_from_bytes(url, data, ft, per_file_chars) + mime = _guess_mime(Path(httpx.URL(url).path or f"file.{ft}").name, ft) + name = Path(httpx.URL(url).path or f"file.{ft}").name + if session_id: + store_fetched_file(session_id, name, data, mime, text, url) + outputs.append( + f"=== 候補 {idx} ===\n" + f"URL: {url}\n" + f"タイトル: {title}\n" + f"形式: {mime}\n" + f"サイズ: {len(data)} bytes\n" + f"文字数: {len(text)}\n" + f"---\n{text}\n" + ) + + return "\n".join(outputs) diff --git a/src/ollama_web/tools/pdf.py b/src/ollama_web/tools/pdf.py new file mode 100644 index 0000000..8dc5abb --- /dev/null +++ b/src/ollama_web/tools/pdf.py @@ -0,0 +1,88 @@ +"""PDF text extraction helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from pypdf import PdfReader + +from ..config import settings + + +def extract_pdf_text(data: bytes, max_chars: int | None = None) -> str: + """Extract text from PDF bytes. + + Args: + data: Raw PDF file bytes. + max_chars: Maximum characters to return (default from settings). + + Returns: + Extracted plain text. Errors are returned as short strings. + """ + if not data: + return "Empty PDF data." + + limit = max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars + + try: + reader = PdfReader(stream=data) + except Exception as exc: # noqa: BLE001 + return f"Failed to parse PDF: {exc}" + + parts: list[str] = [] + for page in reader.pages: + try: + text = page.extract_text() + if text: + parts.append(text) + except Exception: # noqa: BLE001 + continue + + full = "\n\n".join(parts) + if len(full) > limit: + full = full[:limit].rsplit(" ", 1)[0] + "\n…[truncated]" + return full or "No text could be extracted from the PDF." + + +def pdf_to_text(url_or_path: str, max_chars: int | None = None) -> str: + """Extract text from a local PDF path or remote URL. + + Args: + url_or_path: File path or URL pointing to a PDF. + max_chars: Maximum characters to return. + + Returns: + Extracted plain text or an error message. + """ + if not url_or_path or not url_or_path.strip(): + return "Invalid PDF source." + + src = url_or_path.strip() + data: bytes | None = None + if src.startswith("http://") or src.startswith("https://"): + import httpx + from ..config import settings as cfg # local import to avoid top-level cycles + + try: + with httpx.Client( + timeout=cfg.scrape_timeout, + follow_redirects=True, + headers={"User-Agent": "ollama-web/0.1 (+https://github.com/local)"}, + ) as client: + resp = client.get(src) + resp.raise_for_status() + data = resp.content + except Exception as exc: # noqa: BLE001 + return f"Failed to fetch PDF: {exc}" + else: + path = Path(src) + if not path.exists(): + return f"PDF not found: {src}" + try: + data = path.read_bytes() + except Exception as exc: # noqa: BLE001 + return f"Failed to read PDF: {exc}" + + if data is None: + return "No PDF data loaded." + return extract_pdf_text(data, max_chars=max_chars) diff --git a/src/ollama_web/tools/registry.py b/src/ollama_web/tools/registry.py index 92b321c..2b6c7ae 100644 --- a/src/ollama_web/tools/registry.py +++ b/src/ollama_web/tools/registry.py @@ -6,6 +6,8 @@ import json from collections.abc import Callable, Sequence from dataclasses import dataclass, field +from .fetch import search_and_fetch +from .pdf import pdf_to_text from .scrape import scrape_url from .search import web_search @@ -72,6 +74,8 @@ def default_registry() -> ToolRegistry: reg = ToolRegistry() reg.register(web_search) reg.register(scrape_url) + reg.register(search_and_fetch) + reg.register(pdf_to_text) return reg -- cgit v1.2.3