summaryrefslogtreecommitdiff
path: root/src/ollama_web
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-24 00:19:26 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-24 00:19:26 +0900
commit16a8bfebde12e2e3fa50b2a1384091158fdabe91 (patch)
treeab9e64216510560204e38dbe464bca892dc1713a /src/ollama_web
parent5d9fc9c9c976cb6fc7d0b8060abcd2860ebf8456 (diff)
機能追加(セッション、左ペインでのセッション管理、ファイル取得ツール、pdfテキスト化ツール)
Diffstat (limited to 'src/ollama_web')
-rw-r--r--src/ollama_web/app.py36
-rw-r--r--src/ollama_web/config.py8
-rw-r--r--src/ollama_web/routes/chat.py129
-rw-r--r--src/ollama_web/routes/sessions.py120
-rw-r--r--src/ollama_web/sessions.py248
-rw-r--r--src/ollama_web/static/app.js231
-rw-r--r--src/ollama_web/static/style.css164
-rw-r--r--src/ollama_web/templates/index.html72
-rw-r--r--src/ollama_web/tools/fetch.py204
-rw-r--r--src/ollama_web/tools/pdf.py88
-rw-r--r--src/ollama_web/tools/registry.py4
11 files changed, 1251 insertions, 53 deletions
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"<attached-file name=\"{chat_file.name}\" id=\"{chat_file.id}\" "
+ f"source=\"{chat_file.source}\">\n{text}\n</attached-file>"
+ )
+ 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 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
</head>
<body>
- <header>
- <h1>ollama-web</h1>
- <div class="controls">
- <label for="model-select">モデル:</label>
- <select id="model-select">
- {% if models %}
- {% for m in models %}
- <option value="{{ m }}" {% if m == default_model %}selected{% endif %}>{{ m }}</option>
- {% endfor %}
- {% else %}
- <option value="{{ default_model }}" selected>{{ default_model }}</option>
- {% endif %}
- </select>
- <label class="think-toggle">
- <input type="checkbox" id="think-toggle" /> thinking
- </label>
- <button id="clear-btn" type="button">会話クリア</button>
+ <aside id="sidebar">
+ <div class="sidebar-header">
+ <h2>セッション</h2>
+ <button id="new-session-btn" type="button" title="新規セッション">+</button>
</div>
- </header>
+ <ul id="session-list"></ul>
+ </aside>
- <main id="chat"></main>
+ <div id="main">
+ <header>
+ <h1>ollama-web</h1>
+ <div class="controls">
+ <label for="model-select">モデル:</label>
+ <select id="model-select">
+ {% if models %}
+ {% for m in models %}
+ <option value="{{ m }}" {% if m == default_model %}selected{% endif %}>{{ m }}</option>
+ {% endfor %}
+ {% else %}
+ <option value="{{ default_model }}" selected>{{ default_model }}</option>
+ {% endif %}
+ </select>
+ <label class="think-toggle">
+ <input type="checkbox" id="think-toggle" /> thinking
+ </label>
+ <button id="clear-btn" type="button">会話クリア</button>
+ </div>
+ </header>
- <form id="input-form">
- <textarea id="input" placeholder="メッセージを入力… (Enter で送信 / Shift+Enter で改行)"
- rows="2" autofocus></textarea>
- <button type="submit" id="send-btn">送信</button>
- <button type="button" id="cancel-btn" disabled>停止</button>
- </form>
+ <main id="chat"></main>
+
+ <div id="attachments"></div>
+
+ <form id="input-form">
+ <div class="input-row">
+ <label class="attach-btn" title="ファイルを添付">
+ <input type="file" id="file-input" multiple accept="*/*" />
+ 📎
+ </label>
+ <textarea id="input" placeholder="メッセージを入力… (Enter で送信 / Shift+Enter で改行)"
+ rows="2" autofocus></textarea>
+ <button type="submit" id="send-btn">送信</button>
+ <button type="button" id="cancel-btn" disabled>停止</button>
+ </div>
+ </form>
+ </div>
<script>
const TOOLS = [
{ name: "web_search", label: "Web検索" },
{ name: "scrape_url", label: "スクレイピング" },
+ { name: "search_and_fetch", label: "ファイル検索・取得" },
+ { name: "pdf_to_text", label: "PDFテキスト変換" },
];
</script>
<script src="{{ url_for('static', path='app.js') }}"></script>
</body>
-</html>
+</html> \ 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