diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-25 02:41:58 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-25 02:41:58 +0900 |
| commit | 5f948a33412dfde6d0a210746e61ac9c8477c9e8 (patch) | |
| tree | dfbd1877e1a1c43df0777f0daff943620d0dac13 | |
| parent | 697911563a1e8294aa415e8324bdd07901098876 (diff) | |
各モデルの機能を取得するように修正。thinking, tools, vision対応モデルに応じて機能を変えるように変更。クリアボタンのバグを修正
| -rw-r--r-- | src/ollama_web/app.py | 12 | ||||
| -rw-r--r-- | src/ollama_web/llm.py | 96 | ||||
| -rw-r--r-- | src/ollama_web/routes/chat.py | 10 | ||||
| -rw-r--r-- | src/ollama_web/routes/models.py | 13 | ||||
| -rw-r--r-- | src/ollama_web/routes/sessions.py | 11 | ||||
| -rw-r--r-- | src/ollama_web/sessions.py | 21 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 64 | ||||
| -rw-r--r-- | src/ollama_web/static/style.css | 10 | ||||
| -rw-r--r-- | tests/test_clear_messages.py | 63 | ||||
| -rw-r--r-- | tests/test_llm.py | 83 |
10 files changed, 372 insertions, 11 deletions
diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py index cdec188..946d280 100644 --- a/src/ollama_web/app.py +++ b/src/ollama_web/app.py @@ -33,6 +33,12 @@ def create_app() -> Starlette: Route("/", pages_route.index, name="index"), Route("/api/chat", chat_route.chat, methods=["POST"], name="chat"), Route("/api/models", models_route.get_models, methods=["GET"], name="models"), + Route( + "/api/models/{model}/capabilities", + models_route.get_model_capabilities, + methods=["GET"], + name="model_capabilities", + ), Route("/api/health", models_route.health, methods=["GET"], name="health"), Route("/api/sessions", sessions_route.list_sessions, methods=["GET"], name="list_sessions"), Route( @@ -65,6 +71,12 @@ def create_app() -> Starlette: methods=["DELETE"], name="delete_file", ), + Route( + "/api/sessions/{session_id}/messages", + sessions_route.clear_messages, + methods=["DELETE"], + name="clear_messages", + ), Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"), ] diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py index a18abf4..461cef9 100644 --- a/src/ollama_web/llm.py +++ b/src/ollama_web/llm.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json from collections.abc import AsyncIterator, Callable, Iterator from typing import Any, cast @@ -18,6 +19,7 @@ from .tools.registry import ToolRegistry, default_registry MAX_TOOL_ROUNDS = 5 # Upper bound on the cumulative size of tool results sent back to ollama. + # Larger values can cause ollama to spend a very long time processing the # context between rounds. MAX_TOOL_CONTEXT_CHARS = 24000 @@ -55,6 +57,65 @@ def list_models(host: str | None = None) -> list[str]: return models +def get_model_capabilities(model: str, host: str | None = None) -> set[str]: + """Return the capability set for a single model via ``ollama.show()``. + + Unknown or unreachable models are treated as having no special capabilities, + which keeps the UI conservative and avoids sending unsupported parameters. + """ + try: + client = get_client(host) + show_response = client.show(model) + except Exception: # noqa: BLE001 + return set() + + caps = getattr(show_response, "capabilities", None) + if caps is None and isinstance(show_response, dict): + caps = show_response.get("capabilities") + if not caps: + return set() + return {str(c).lower() for c in caps} + + +def _is_image_message(message: Message) -> bool: + """Check whether a message has image attachments.""" + images = getattr(message, "images", None) + return bool(images) + + +def _embed_images_in_content(message: Message) -> Message: + """Convert image bytes attached to a message into base64 markdown links. + + Non-vision models cannot consume the ``images`` field, so we fall back to + embedding the image as data URIs inside the text content. + """ + images = getattr(message, "images", None) or [] + if not images: + return message + + parts: list[str] = [] + for img in images: + raw = getattr(img, "value", None) or (img if isinstance(img, (bytes, bytearray)) else None) + if raw is None: + continue + encoded = base64.b64encode(bytes(raw)).decode("ascii") + # Use a generic data URI since ollama already processed the image. + parts.append(f"") + + content = str(getattr(message, "content", "") or "") + if parts: + content = content + "\n\n" + "\n\n".join(parts) + + kwargs: dict[str, Any] = {"role": getattr(message, "role", "user"), "content": content} + for attr in ("name", "tool_calls", "tool_name"): + value = getattr(message, attr, None) + if value is not None: + kwargs[attr] = value + if getattr(message, "thinking", None): + kwargs["thinking"] = message.thinking + return Message(**kwargs) + + def _tool_calls(response: Any) -> list[dict[str, Any]]: """Extract tool calls from an ollama response in a tolerant manner.""" msg = getattr(response, "message", None) or ( @@ -108,6 +169,7 @@ def chat_with_tools( think: bool | str | None = None, registry: ToolRegistry | None = None, host: str | None = None, + capabilities: set[str] | None = None, ) -> Iterator[dict[str, Any]]: """Run a multi-round tool-calling chat and yield progress events. @@ -126,9 +188,16 @@ def chat_with_tools( logger = logging.getLogger("ollama_web.llm") + caps = capabilities or set() raw_client = get_client(host) reg = registry or default_registry() tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables] + effective_tools = tools if "tools" in caps else [] + effective_think = think if "thinking" in caps else None + + # Non-vision models cannot use the ``images`` field; embed them as base64. + if "vision" not in caps: + messages = [_embed_images_in_content(m) if _is_image_message(m) else m for m in messages] cumulative_tool_chars = 0 @@ -147,8 +216,8 @@ def chat_with_tools( resp = cast(Any, raw_client).chat( model=model, messages=messages, - tools=tools, - think=think, + tools=effective_tools, + think=effective_think, stream=False, ) except Exception as exc: # noqa: BLE001 @@ -221,7 +290,8 @@ def chat_with_tools( resp = cast(Any, raw_client).chat( model=model, messages=messages, - think=think, + tools=effective_tools, + think=effective_think, stream=False, ) except Exception as exc: # noqa: BLE001 @@ -256,6 +326,7 @@ def stream_chat_with_tools( think: bool | str | None = None, registry: ToolRegistry | None = None, host: str | None = None, + capabilities: set[str] | None = None, ) -> Iterator[dict[str, Any]]: """Run a tool-calling chat loop and yield real streaming events from ollama. @@ -268,9 +339,16 @@ def stream_chat_with_tools( logger = logging.getLogger("ollama_web.llm") + caps = capabilities or set() raw_client = get_client(host) reg = registry or default_registry() tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables] + effective_tools = tools if "tools" in caps else [] + effective_think = think if "thinking" in caps else None + + # Non-vision models cannot use the ``images`` field; embed them as base64. + if "vision" not in caps: + messages = [_embed_images_in_content(m) if _is_image_message(m) else m for m in messages] cumulative_tool_chars = 0 @@ -292,8 +370,8 @@ def stream_chat_with_tools( stream = cast(Any, raw_client).chat( model=model, messages=messages, - tools=tools, - think=think, + tools=effective_tools, + think=effective_think, stream=True, ) except Exception as exc: # noqa: BLE001 @@ -412,7 +490,8 @@ def stream_chat_with_tools( stream = cast(Any, raw_client).chat( model=model, messages=messages, - think=think, + tools=effective_tools, + think=effective_think, stream=True, ) except Exception as exc: # noqa: BLE001 @@ -439,11 +518,14 @@ async def astream_chat_with_tools( think: bool | str | None = None, registry: ToolRegistry | None = None, host: str | None = None, + capabilities: set[str] | None = None, ) -> AsyncIterator[dict[str, Any]]: """Async wrapper that yields events from ``stream_chat_with_tools``.""" import anyio - gen = stream_chat_with_tools(messages, model, think=think, registry=registry, host=host) + gen = stream_chat_with_tools( + messages, model, think=think, registry=registry, host=host, capabilities=capabilities + ) def _next() -> dict[str, Any] | None: try: diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index 9e0eae6..8b5c3f4 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -202,8 +202,16 @@ async def _chat_event_stream( total_image_bytes, ) + capabilities = llm.get_model_capabilities(model, host=host) + logger.info( + "chat stream model=%s capabilities=%s", + model, + sorted(capabilities), + ) + async for event in llm.astream_chat_with_tools( - messages, model, think=think, host=host, registry=registry + messages, model, think=think, host=host, registry=registry, + capabilities=capabilities, ): if event.get("type") == "delta": assistant_text = _limit_text( diff --git a/src/ollama_web/routes/models.py b/src/ollama_web/routes/models.py index 477a44e..406679a 100644 --- a/src/ollama_web/routes/models.py +++ b/src/ollama_web/routes/models.py @@ -15,6 +15,19 @@ async def get_models(request: Request) -> JSONResponse: return JSONResponse({"models": models}) +async def get_model_capabilities(request: Request) -> JSONResponse: + """Return capabilities for a single model via ``ollama.show()``. + + The response contains the raw capability strings reported by ollama, e.g. + ``{"capabilities": ["vision", "tools", "thinking"]}``. Unknown models or + connection failures return an empty list. + """ + model = request.path_params["model"] + host = request.app.state.settings.ollama_host + caps = sorted(llm.get_model_capabilities(model, host=host)) + return JSONResponse({"capabilities": caps}) + + async def health(request: Request) -> JSONResponse: """Report Ollama server reachability.""" host = request.app.state.settings.ollama_host diff --git a/src/ollama_web/routes/sessions.py b/src/ollama_web/routes/sessions.py index 7b01705..64e1bc9 100644 --- a/src/ollama_web/routes/sessions.py +++ b/src/ollama_web/routes/sessions.py @@ -161,3 +161,14 @@ async def delete_file(request: Request) -> JSONResponse: if not ok: return JSONResponse({"error": "File not found"}, status_code=404) return JSONResponse({"deleted": True}) + + +async def clear_messages(request: Request) -> JSONResponse: + store = _get_store(request) + session_id = request.path_params["session_id"] + if not is_valid_id(session_id): + return JSONResponse({"error": "Invalid session id"}, status_code=400) + session = store.clear_messages(session_id) + if session is None: + return JSONResponse({"error": "Session not found"}, status_code=404) + return JSONResponse({"cleared": True, "session": session}) diff --git a/src/ollama_web/sessions.py b/src/ollama_web/sessions.py index 3eab31f..645f934 100644 --- a/src/ollama_web/sessions.py +++ b/src/ollama_web/sessions.py @@ -314,3 +314,24 @@ class SessionStore: return abs_path.read_bytes() except Exception: # noqa: BLE001 return None + + def clear_messages(self, session_id: str) -> dict[str, Any] | None: + """Clear all messages and attached files from the session.""" + if not is_valid_id(session_id): + return None + session = self.get(session_id) + if session is None: + return None + # Remove uploaded/fetched physical files. + files_dir = self._files_dir(session_id) + try: + if files_dir.exists(): + shutil.rmtree(files_dir) + files_dir.mkdir(parents=True, exist_ok=True) + except Exception: # noqa: BLE001 + pass + session["messages"] = [] + session["files"] = [] + session["updated_at"] = _now() + self._save(session_id, session) + return session diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 3240dcc..b9da89f 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -21,6 +21,34 @@ let currentSessionId = null; let currentSession = null; let pendingFiles = []; let abortController = null; +let currentCapabilities = new Set(); + +function updateThinkToggleState() { + if (!thinkToggle) return; + if (currentCapabilities.has("thinking")) { + thinkToggle.disabled = false; + thinkToggle.parentElement.classList.remove("disabled"); + } else { + thinkToggle.disabled = true; + thinkToggle.checked = false; + thinkToggle.parentElement.classList.add("disabled"); + } +} + +async function loadModelCapabilities() { + const model = modelSelect.value; + if (!model) return; + try { + const res = await fetch(`/api/models/${encodeURIComponent(model)}/capabilities`); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + currentCapabilities = new Set((data.capabilities || []).map((c) => c.toLowerCase())); + } catch (err) { + console.error("failed to load model capabilities", err); + currentCapabilities = new Set(); + } + updateThinkToggleState(); +} function getCookie(name) { const prefix = `${name}=`; @@ -440,6 +468,8 @@ sessionListEl.addEventListener("click", (e) => { } }); +modelSelect.addEventListener("change", loadModelCapabilities); + async function send() { const text = inputEl.value.trim(); if (!text && !pendingFiles.length) return; @@ -669,9 +699,36 @@ inputEl.addEventListener("keydown", (e) => { } }); -clearBtn.addEventListener("click", () => { - chatEl.innerHTML = ""; -}); +async function clearMessages() { + if (!currentSessionId) { + chatEl.innerHTML = ""; + pendingFiles = []; + renderAttachments(); + return; + } + + try { + const res = await csrfFetch(`/api/sessions/${currentSessionId}/messages`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(res.statusText); + const data = await res.json(); + if (!data.cleared) throw new Error("クリアに失敗しました"); + + if (currentSession) { + currentSession.messages = []; + currentSession.files = []; + } + pendingFiles = []; + chatEl.innerHTML = ""; + renderAttachments(); + } catch (err) { + console.error("failed to clear messages", err); + alert("メッセージのクリアに失敗しました"); + } +} + +clearBtn.addEventListener("click", clearMessages); cancelBtn.addEventListener("click", () => { if (abortController) { @@ -680,3 +737,4 @@ cancelBtn.addEventListener("click", () => { }); loadSessions(); +loadModelCapabilities(); diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css index 14dce76..24623c5 100644 --- a/src/ollama_web/static/style.css +++ b/src/ollama_web/static/style.css @@ -333,6 +333,16 @@ button { cursor: pointer; } button:hover { border-color: var(--accent); } .think-toggle { display: flex; align-items: center; gap: 4px; color: var(--muted); } +.think-toggle.disabled, +.think-toggle:has(input:disabled) { + color: var(--muted); + opacity: 0.5; + cursor: not-allowed; +} +.think-toggle.disabled input[type="checkbox"], +.think-toggle:has(input:disabled) input[type="checkbox"] { + cursor: not-allowed; +} main#chat { flex: 1; diff --git a/tests/test_clear_messages.py b/tests/test_clear_messages.py new file mode 100644 index 0000000..91a2556 --- /dev/null +++ b/tests/test_clear_messages.py @@ -0,0 +1,63 @@ +"""Tests for clearing session messages and files.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from ollama_web.sessions import SessionStore, is_valid_id + + +def make_store() -> SessionStore: + tmp = tempfile.mkdtemp() + return SessionStore(data_dir=tmp) + + +def test_clear_messages_removes_history_and_files(): + store = make_store() + session = store.create(title="test") + session_id = session["id"] + assert is_valid_id(session_id) + + chat_file = store.add_file( + session_id=session_id, + name="notes.txt", + data=b"hello", + mime="text/plain", + text="hello", + ) + store.add_message( + session_id=session_id, + role="user", + content="hi", + attachments=[chat_file.id], + ) + store.add_message(session_id=session_id, role="assistant", content="hello") + + files_dir = store._files_dir(session_id) + stored_path = store.data_dir / chat_file.path + assert stored_path.exists() + assert (files_dir / f"{chat_file.id}.txt").exists() + + cleared = store.clear_messages(session_id) + assert cleared is not None + assert cleared["messages"] == [] + assert cleared["files"] == [] + assert not stored_path.exists() + assert files_dir.exists() + + reloaded = store.get(session_id) + assert reloaded is not None + assert reloaded["messages"] == [] + assert reloaded["files"] == [] + + +def test_clear_messages_invalid_session_id(): + store = make_store() + assert store.clear_messages("not-a-valid-id") is None + + +def test_clear_messages_missing_session(): + store = make_store() + assert store.clear_messages("0" * 32) is None
\ No newline at end of file diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..cbb9910 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,83 @@ +"""Tests for llm capability detection and feature fallbacks.""" + +from __future__ import annotations + +import base64 + +import pytest +from ollama import Message +from ollama._types import Image as OllamaImage +from ollama._types import ResponseError + +from ollama_web.llm import ( + _embed_images_in_content, + _is_image_message, + chat_with_tools, + stream_chat_with_tools, +) +from ollama_web.tools.registry import ToolRegistry + + +def _image_message() -> Message: + return Message( + role="user", + content="hello", + images=[OllamaImage(value=b"fake-image-bytes")], + ) + + +def test_is_image_message_detects_images() -> None: + assert _is_image_message(_image_message()) is True + + +def test_is_image_message_without_images() -> None: + assert _is_image_message(Message(role="user", content="hello")) is False + + +def test_embed_images_in_content_adds_base64_markdown() -> None: + embedded = _embed_images_in_content(_image_message()) + encoded = base64.b64encode(b"fake-image-bytes").decode("ascii") + assert f"" in embedded.content + assert not getattr(embedded, "images", None) + + +def test_chat_with_tools_skips_tools_for_non_tool_model() -> None: + """Tools are not passed when the model does not advertise tool support.""" + messages = [Message(role="user", content="hi")] + # Use an empty registry so any tool call would fail if requested. + registry = ToolRegistry() + + # Without tool capability, the iterator should finish immediately with no + # actual network request. We only verify it accepts the call and does not + # raise due to tool-related setup. + gen = chat_with_tools( + messages, + model="nonexistent-model", + capabilities=set(), + registry=registry, + ) + events = list(gen) + # We expect either an error (model not found) or a done event. + assert any(e.get("type") in {"error", "done"} for e in events) + + +def test_chat_with_tools_forces_think_none_for_non_thinking_model() -> None: + messages = [Message(role="user", content="hi")] + gen = chat_with_tools( + messages, + model="nonexistent-model", + capabilities=set(), + ) + events = list(gen) + assert any(e.get("type") in {"error", "done"} for e in events) + + +def test_stream_chat_with_tools_accepts_capability_set() -> None: + messages = [Message(role="user", content="hi")] + gen = stream_chat_with_tools( + messages, + model="nonexistent-model", + capabilities={"tools", "thinking"}, + ) + with pytest.raises(ResponseError): + list(gen) |
