diff options
Diffstat (limited to 'src/ollama_web')
| -rw-r--r-- | src/ollama_web/routes/chat.py | 184 | ||||
| -rw-r--r-- | src/ollama_web/routes/sessions.py | 18 | ||||
| -rw-r--r-- | src/ollama_web/sessions.py | 37 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 83 | ||||
| -rw-r--r-- | src/ollama_web/tools/__init__.py | 21 | ||||
| -rw-r--r-- | src/ollama_web/tools/image.py | 72 |
6 files changed, 336 insertions, 79 deletions
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index 8b6c302..08b330a 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -10,11 +10,13 @@ from collections.abc import AsyncIterator from typing import Any from ollama import Message +from ollama._types import Image as OllamaImage from starlette.requests import Request from starlette.responses import StreamingResponse from ..sessions import SessionStore from ..tools.fetch import pop_fetched_files +from ..tools.image import resize_image from ..tools.registry import ToolRegistry, default_registry logger = logging.getLogger("ollama_web.chat") @@ -32,7 +34,8 @@ def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]: if name: kwargs["name"] = name if images: - kwargs["images"] = images + # ollama expects a sequence of ollama._types.Image objects. + kwargs["images"] = [OllamaImage(value=img) for img in images] out.append(Message(**kwargs)) return out @@ -60,7 +63,7 @@ def _attachment_context(store: SessionStore, session_id: str, file_ids: list[str parts: list[str] = [] for file_id in file_ids: chat_file = store.get_file(session_id, file_id) - if chat_file is None: + if chat_file is None or chat_file.is_image: continue text = chat_file.text or "" parts.append( @@ -72,88 +75,151 @@ def _attachment_context(store: SessionStore, session_id: str, file_ids: list[str return "\n\n".join(["以下はユーザーが添付したファイルの内容です:", *parts]) +def _collect_images(store: SessionStore, session_id: str, file_ids: list[str]) -> list[bytes]: + images: list[bytes] = [] + for file_id in file_ids: + chat_file = store.get_file(session_id, file_id) + if chat_file is None or not chat_file.is_image: + continue + data = store.get_file_data(session_id, file_id) + if data is None: + continue + try: + resized = resize_image(data, name=chat_file.name, max_dimension=1024, quality=85) + images.append(resized) + except Exception: # noqa: BLE001 + logger.warning( + "failed to resize image %s, falling back to original bytes", chat_file.name + ) + images.append(data) + return images + + 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.""" + # Send a keepalive comment immediately so the browser knows the connection is alive. + yield b":\n\n" + async for chunk in _chat_event_stream(request, payload, session_id): + data = json.dumps(chunk, ensure_ascii=False) + yield f"data: {data}\n\n".encode() + + +async def _chat_event_stream( + request: Request, + payload: dict[str, Any], + session_id: str | None, +) -> AsyncIterator[dict[str, Any]]: + """Core chat loop yielding events. Exceptions are converted to SSE error events.""" model = payload.get("model") or request.app.state.settings.default_model 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 "") + assistant_text = "" - attachment_ctx = "" - if session_id and file_ids: - attachment_ctx = _attachment_context(store, session_id, file_ids) + try: + session_messages: list[Message] = [] + if session_id: + session = store.get(session_id) + if session is not None: + session_messages = _parse_messages(session.get("messages", [])) - if attachment_ctx and user_content: - # Inject attachment context into the latest user message. - augmented = f"{user_content}\n\n{attachment_ctx}" + raw_messages = payload.get("messages", []) if raw_messages: - raw_messages[-1]["content"] = augmented - elif attachment_ctx: - raw_messages.append({"role": "user", "content": attachment_ctx}) + user_content = str(raw_messages[-1].get("content", "") or "") - messages = _parse_messages(raw_messages) - if session_messages: - messages = session_messages + messages + attachment_ctx = "" + if session_id and file_ids: + attachment_ctx = _attachment_context(store, session_id, file_ids) - host = request.app.state.settings.ollama_host + 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}) - from ollama_web import llm # local import to avoid circular at module load + # Attach image bytes to the latest user message per ollama spec. + if session_id and file_ids and raw_messages: + images = _collect_images(store, session_id, file_ids) + if images: + raw_messages[-1]["images"] = images - registry = _session_aware_registry(session_id) + messages = _parse_messages(raw_messages) + if session_messages: + messages = session_messages + messages - 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() + host = request.app.state.settings.ollama_host + + from ollama_web import llm # local import to avoid circular at module load - # 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, + registry = _session_aware_registry(session_id) + + total_image_bytes = sum( + len(img.value) for img in (getattr(messages[-1], "images", None) or []) + ) + logger.info( + "chat stream model=%s session=%s messages=%d images=%d image_bytes=%d", + model, + session_id, + len(messages), + len(getattr(messages[-1], "images", []) or []), + total_image_bytes, ) - 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( + 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", "") + yield event + + except Exception as exc: # noqa: BLE001 + logger.exception("chat stream failed") + error_msg = f"Stream failed: {exc}" + yield {"type": "error", "message": error_msg} + # Persist the error as the assistant message so it survives reloads + # and re-renders in the chat pane. + assistant_text = f"[ERROR] {error_msg}\n\n画像を含むメッセージを送信する場合、vision 対応モデルを選択してください。" + + finally: + # Persist user and assistant messages to the session even when the + # upstream call failed, so the UI keeps the user message. + if session_id and user_content: + store.add_message( session_id=session_id, - name=f["name"], - data=f["data"], - mime=f["mime"], - text=f["text"], - source="fetch", + 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", + ) + + # Always emit a terminal done event so the client closes cleanly. + yield {"type": "done"} async def chat(request: Request) -> StreamingResponse: diff --git a/src/ollama_web/routes/sessions.py b/src/ollama_web/routes/sessions.py index 8980960..8640ec3 100644 --- a/src/ollama_web/routes/sessions.py +++ b/src/ollama_web/routes/sessions.py @@ -8,7 +8,7 @@ from typing import Any from starlette.requests import Request from starlette.responses import JSONResponse -from ..sessions import SessionStore +from ..sessions import SessionStore, _IMAGE_MIMES, _IMAGE_SUFFIXES from ..tools.pdf import extract_pdf_text logger = logging.getLogger("ollama_web.sessions") @@ -18,6 +18,17 @@ def _get_store(request: Request) -> SessionStore: return request.app.state.session_store +def _is_image_file(name: str, mime: str) -> bool: + mime_lower = mime.lower() + name_lower = name.lower() + if mime_lower in _IMAGE_MIMES: + return True + for suffix in _IMAGE_SUFFIXES: + if name_lower.endswith(suffix): + return True + return False + + async def list_sessions(request: Request) -> JSONResponse: store = _get_store(request) return JSONResponse({"sessions": store.list_sessions()}) @@ -85,7 +96,10 @@ async def upload_file(request: Request) -> JSONResponse: 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"): + if _is_image_file(name, content_type): + # Images are passed to ollama via Message.images (base64), not as text. + text = "" + elif content_type == "application/pdf" or name.lower().endswith(".pdf"): text = extract_pdf_text(data) else: # Try decode as text for text-like uploads. diff --git a/src/ollama_web/sessions.py b/src/ollama_web/sessions.py index bfe659c..7cc06ad 100644 --- a/src/ollama_web/sessions.py +++ b/src/ollama_web/sessions.py @@ -17,6 +17,20 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() +_IMAGE_MIMES: set[str] = { + "image/png", + "image/jpeg", + "image/gif", + "image/bmp", + "image/webp", + "image/tiff", + "image/x-png", + "image/jpg", +} + +_IMAGE_SUFFIXES: set[str] = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tif", ".tiff"} + + @dataclass class ChatFile: id: str @@ -50,6 +64,16 @@ class ChatFile: source=data.get("source", "upload"), ) + @property + def is_image(self) -> bool: + name_lower = self.name.lower() + if self.mime.lower() in _IMAGE_MIMES: + return True + for suffix in _IMAGE_SUFFIXES: + if name_lower.endswith(suffix): + return True + return False + @dataclass class ChatMessage: @@ -245,4 +269,15 @@ class SessionStore: for f in session.get("files", []): if f["id"] == file_id: return ChatFile.from_dict(f) - return None
\ No newline at end of file + return None + + def get_file_data(self, session_id: str, file_id: str) -> bytes | None: + chat_file = self.get_file(session_id, file_id) + if chat_file is None: + return None + abs_path = self.data_dir / chat_file.path + try: + return abs_path.read_bytes() + except Exception: # noqa: BLE001 + return None + diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 3a75ded..22fac53 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -107,19 +107,27 @@ function addThinkingBubble(parentExtras) { function parseSseEvent(chunk) { const events = []; - const rawEvents = chunk.split("\n\n"); - for (const raw of rawEvents) { - const lines = raw.split("\n"); - const dataLines = []; - for (const line of lines) { - if (line.startsWith("data: ")) { - dataLines.push(line.slice(6)); - } + const lines = chunk.split("\n"); + let dataBuffer = ""; + + function flush() { + if (dataBuffer !== "") { + events.push(dataBuffer); + dataBuffer = ""; } - if (dataLines.length) { - events.push(dataLines.join("")); + } + + for (const line of lines) { + if (line === "") { + flush(); + } else if (line.startsWith("data: ")) { + const payload = line.slice(6); + dataBuffer = dataBuffer === "" ? payload : dataBuffer + "\n" + payload; } + // Lines starting with ':' are comments (e.g. keepalive). All other field + // lines are ignored because we only care about 'data:' payloads. } + flush(); return events; } @@ -211,6 +219,32 @@ async function selectSession(id) { } } +function escapeHtml(text) { + return text + .replace(/&/g, String.fromCharCode(38, 97, 109, 112, 59)) + .replace(/</g, String.fromCharCode(38, 108, 116, 59)) + .replace(/>/g, String.fromCharCode(38, 103, 116, 59)) + .replace(/"/g, String.fromCharCode(38, 113, 117, 111, 116, 59)) + .replace(/'/g, "'"); +} + +function _renderAssistantContent(body, content) { + if (content && content.startsWith("[ERROR]")) { + const lines = content.slice(7).split("\n").filter((l) => l.trim()); + body.innerHTML = ""; + for (const line of lines) { + const span = document.createElement("span"); + span.style.color = "#f85149"; + span.textContent = line; + body.appendChild(span); + body.appendChild(document.createElement("br")); + } + } else { + body.innerHTML = renderMarkdown(content); + highlightIn(body); + } +} + function renderSession() { chatEl.innerHTML = ""; if (!currentSession) return; @@ -218,7 +252,8 @@ function renderSession() { for (const m of msgs) { if (m.role === "user") { const body = addMessage("user").body; - body.innerHTML = renderMarkdown(m.content); + const content = m.content || ""; + body.innerHTML = renderMarkdown(content); highlightIn(body); if (m.attachments && m.attachments.length) { const info = document.createElement("div"); @@ -236,8 +271,7 @@ function renderSession() { } } else if (m.role === "assistant") { const body = addMessage("assistant").body; - body.innerHTML = renderMarkdown(m.content); - highlightIn(body); + _renderAssistantContent(body, m.content); } } } @@ -353,6 +387,7 @@ async function send() { let assistantText = ""; let thinkingBody = null; const pendingTools = []; + let gotError = false; const model = modelSelect.value; const think = thinkToggle.checked; @@ -412,7 +447,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); @@ -444,7 +479,12 @@ async function send() { } else if (ev.type === "delta") { appendDelta(ev.content); } else if (ev.type === "error") { - assistantBody.innerHTML += `<span style="color:#f85149">エラー: ${ev.message}</span>`; + gotError = true; + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + assistantBody.innerHTML += `<span style="color:#f85149">エラー: ${escapeHtml(ev.message)}</span>`; } else if (ev.type === "done") { break; } @@ -470,7 +510,7 @@ async function send() { } } - if (!assistantText && !pendingTools.length && !thinkingBody) { + if (!assistantText && !pendingTools.length && !thinkingBody && !gotError) { assistantBody.textContent = "(応答なし)"; } @@ -479,7 +519,18 @@ async function send() { sendBtn.disabled = false; inputEl.focus(); + // Preserve any error message that was rendered during the stream so it is + // not lost when renderSession() rebuilds the chat pane. + const errorHtml = gotError ? assistantBody.innerHTML : null; + await selectSession(currentSessionId); + + if (errorHtml && chatEl.lastElementChild) { + const lastBody = chatEl.lastElementChild.querySelector(".body"); + if (lastBody && lastBody.textContent.trim() === "(応答なし)") { + lastBody.innerHTML = errorHtml; + } + } } formEl.addEventListener("submit", (e) => { diff --git a/src/ollama_web/tools/__init__.py b/src/ollama_web/tools/__init__.py index c034c94..17ac505 100644 --- a/src/ollama_web/tools/__init__.py +++ b/src/ollama_web/tools/__init__.py @@ -1 +1,20 @@ -"""Tool package for ollama-web."""
\ No newline at end of file +"""Tool package for ollama-web.""" + +from .fetch import search_and_fetch +from .image import resize_image +from .pdf import extract_pdf_text, pdf_to_text +from .registry import ToolRegistry, all_tool_callables, default_registry +from .scrape import scrape_url +from .search import web_search + +__all__ = [ + "search_and_fetch", + "resize_image", + "extract_pdf_text", + "pdf_to_text", + "ToolRegistry", + "all_tool_callables", + "default_registry", + "scrape_url", + "web_search", +] diff --git a/src/ollama_web/tools/image.py b/src/ollama_web/tools/image.py new file mode 100644 index 0000000..8bc14f5 --- /dev/null +++ b/src/ollama_web/tools/image.py @@ -0,0 +1,72 @@ +"""Image preprocessing helpers for ollama vision models.""" + +from __future__ import annotations + +import io +from pathlib import Path + +from PIL import Image + + +_IMAGE_FORMATS: dict[str, str] = { + "png": "PNG", + "jpg": "JPEG", + "jpeg": "JPEG", + "gif": "GIF", + "bmp": "BMP", + "webp": "WEBP", + "tif": "TIFF", + "tiff": "TIFF", +} + + +def _format_from_name(name: str) -> str: + ext = Path(name).suffix.lstrip(".").lower() + return _IMAGE_FORMATS.get(ext, "JPEG") + + +def resize_image( + data: bytes, + name: str = "image.jpg", + max_dimension: int = 1024, + quality: int = 85, +) -> bytes: + """Resize and re-encode an image to reduce payload size for ollama. + + Args: + data: Raw image bytes. + name: Original file name, used to guess the output format. + max_dimension: Maximum width or height in pixels. + quality: JPEG/WebP quality when re-encoding to a lossy format. + + Returns: + Resized image bytes. If the input is already small, it may be returned + unchanged when re-encoding is not needed. + """ + if not data: + return data + + try: + with Image.open(io.BytesIO(data)) as img: + # Preserve orientation if EXIF data exists. + img = img.convert("RGB") + + width, height = img.size + if width > max_dimension or height > max_dimension: + ratio = min(max_dimension / width, max_dimension / height) + new_size = (int(width * ratio), int(height * ratio)) + img = img.resize(new_size, Image.Resampling.LANCZOS) + + fmt = _format_from_name(name) + output = io.BytesIO() + save_kwargs: dict[str, object] = {} + if fmt == "JPEG": + save_kwargs["quality"] = quality + save_kwargs["optimize"] = True + elif fmt == "WEBP": + save_kwargs["quality"] = quality + img.save(output, format=fmt, **save_kwargs) + return output.getvalue() + except Exception: # noqa: BLE001 + # Fall back to the original bytes if anything goes wrong. + return data
\ No newline at end of file |
