summaryrefslogtreecommitdiff
path: root/src/ollama_web
diff options
context:
space:
mode:
Diffstat (limited to 'src/ollama_web')
-rw-r--r--src/ollama_web/app.py12
-rw-r--r--src/ollama_web/llm.py96
-rw-r--r--src/ollama_web/routes/chat.py10
-rw-r--r--src/ollama_web/routes/models.py13
-rw-r--r--src/ollama_web/routes/sessions.py11
-rw-r--r--src/ollama_web/sessions.py21
-rw-r--r--src/ollama_web/static/app.js64
-rw-r--r--src/ollama_web/static/style.css10
8 files changed, 226 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"![image](data:image/png;base64,{encoded})")
+
+ 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;