summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pyproject.toml1
-rw-r--r--requirements.txt1
-rw-r--r--src/ollama_web/routes/chat.py184
-rw-r--r--src/ollama_web/routes/sessions.py18
-rw-r--r--src/ollama_web/sessions.py37
-rw-r--r--src/ollama_web/static/app.js83
-rw-r--r--src/ollama_web/tools/__init__.py21
-rw-r--r--src/ollama_web/tools/image.py72
8 files changed, 338 insertions, 79 deletions
diff --git a/pyproject.toml b/pyproject.toml
index a7cc88d..5adc2c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
"jinja2>=3.1",
"pypdf>=4.0",
"python-multipart>=0.0.9",
+ "Pillow>=10.0",
]
[project.optional-dependencies]
diff --git a/requirements.txt b/requirements.txt
index 64f028c..92f4afe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,4 @@ beautifulsoup4>=0.0.1
jinja2>=3.1
pypdf>=4.0
python-multipart>=0.0.9
+Pillow>=10.0
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, "&#039;");
+}
+
+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