summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-23 18:37:46 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-23 18:37:46 +0900
commit21b59cc1d0928dc04dcf5eac24470aaa05013b2d (patch)
tree9888aa23aafea3907ce96eb958a06d4369dad86e
parentde0513e7cb0593b1aa141e367ff4da7156d6110f (diff)
基本機能実装
-rw-r--r--pyproject.toml5
-rw-r--r--requirements.txt7
-rw-r--r--src/ollama_web/app.py46
-rw-r--r--src/ollama_web/config.py26
-rw-r--r--src/ollama_web/llm.py296
-rw-r--r--src/ollama_web/main.py15
-rw-r--r--src/ollama_web/routes/chat.py72
-rw-r--r--src/ollama_web/routes/models.py23
-rw-r--r--src/ollama_web/routes/pages.py27
-rw-r--r--src/ollama_web/static/app.js264
-rw-r--r--src/ollama_web/static/style.css149
-rw-r--r--src/ollama_web/templates/index.html50
-rw-r--r--src/ollama_web/tools/__init__.py1
-rw-r--r--src/ollama_web/tools/registry.py81
-rw-r--r--src/ollama_web/tools/scrape.py59
-rw-r--r--src/ollama_web/tools/search.py45
-rw-r--r--tests/__init__.py12
-rw-r--r--tests/test_tools.py100
18 files changed, 1265 insertions, 13 deletions
diff --git a/pyproject.toml b/pyproject.toml
index 236cc4f..4a8ba25 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,11 @@ requires-python = ">=3.10"
dependencies = [
"ollama>=0.6.2",
"ddgs>=9.14.4",
+ "starlette>=0.37",
+ "uvicorn>=0.30",
+ "httpx>=0.27",
+ "beautifulsoup4>=0.0.1",
+ "jinja2>=3.1",
]
[project.optional-dependencies]
diff --git a/requirements.txt b/requirements.txt
index f42d6de..db09b9c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,7 @@
ollama>=0.6.2
-ddgs>=9.14.4 \ No newline at end of file
+ddgs>=9.14.4
+starlette>=0.37
+uvicorn>=0.30
+httpx>=0.27
+beautifulsoup4>=0.0.1
+jinja2>=3.1
diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py
new file mode 100644
index 0000000..d37557d
--- /dev/null
+++ b/src/ollama_web/app.py
@@ -0,0 +1,46 @@
+"""Starlette application factory for ollama-web."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from starlette.applications import Starlette
+from starlette.middleware import Middleware
+from starlette.middleware.cors import CORSMiddleware
+from starlette.routing import Mount, Route
+from starlette.staticfiles import StaticFiles
+
+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
+
+_STATIC_DIR = Path(__file__).resolve().parent / "static"
+
+
+def create_app() -> Starlette:
+ """Build and return the configured Starlette application."""
+ routes = [
+ 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/health", models_route.health, methods=["GET"], name="health"),
+ Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
+ ]
+
+ middleware = [
+ Middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+ ]
+
+ app = Starlette(routes=routes, middleware=middleware)
+ app.state.settings = settings
+ app.state.tool_names = ["web_search", "scrape_url"]
+ return app
+
+
+app = create_app() \ No newline at end of file
diff --git a/src/ollama_web/config.py b/src/ollama_web/config.py
new file mode 100644
index 0000000..4f39d17
--- /dev/null
+++ b/src/ollama_web/config.py
@@ -0,0 +1,26 @@
+"""Application configuration for ollama-web."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Settings:
+ """Runtime settings resolved from environment variables."""
+
+ ollama_host: str = field(default_factory=lambda: os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434"))
+ default_model: str = field(
+ default_factory=lambda: os.environ.get("OLLAMA_WEB_MODEL", "llama3.2")
+ )
+ 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")))
+ ollama_timeout: float = 120.0
+ scrape_timeout: float = 15.0
+ scrape_max_chars: int = 4000
+ search_max_results: int = 5
+
+
+settings = Settings()
+"""Process-wide settings instance.""" \ No newline at end of file
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py
new file mode 100644
index 0000000..fbe043a
--- /dev/null
+++ b/src/ollama_web/llm.py
@@ -0,0 +1,296 @@
+"""Ollama client wrapper with tool-calling chat loop and streaming."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncIterator, Callable, Iterator
+from typing import Any, cast
+
+import ollama
+from ollama import Message
+
+from .config import settings
+from .tools.registry import ToolRegistry, default_registry
+
+# Maximum successive tool-call rounds before we force a final answer.
+MAX_TOOL_ROUNDS = 5
+
+# Type alias matching ollama's expected tools signature.
+ToolCallable = Callable[..., Any]
+
+
+def get_client(host: str | None = None) -> ollama.Client:
+ """Return an ``ollama.Client`` configured for the configured host."""
+ return ollama.Client(host=host or settings.ollama_host, timeout=settings.ollama_timeout)
+
+
+def list_models(host: str | None = None) -> list[str]:
+ """Return the list of locally available model names."""
+ client = get_client(host)
+ try:
+ resp = client.list()
+ except Exception: # noqa: BLE001
+ return []
+ models: list[str] = []
+ for item in resp.get("models", []) if isinstance(resp, dict) else getattr(resp, "models", []):
+ name = item.get("model") if isinstance(item, dict) else getattr(item, "model", None)
+ if name:
+ models.append(str(name))
+ return models
+
+
+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 (
+ response.get("message") if isinstance(response, dict) else None
+ )
+ if msg is None:
+ return []
+ calls = getattr(msg, "tool_calls", None) if not isinstance(msg, dict) else msg.get("tool_calls")
+ if not calls:
+ return []
+ out: list[dict[str, Any]] = []
+ for c in calls:
+ if isinstance(c, dict):
+ fn = c.get("function", {})
+ name = fn.get("name") or c.get("name", "")
+ args = fn.get("arguments") or c.get("arguments", {})
+ out.append({"name": name, "arguments": args})
+ else:
+ fn = getattr(c, "function", None)
+ if fn is not None:
+ name = getattr(fn, "name", "")
+ args = getattr(fn, "arguments", {})
+ else:
+ name = getattr(c, "name", "")
+ args = getattr(c, "arguments", {})
+ out.append({"name": name, "arguments": args})
+ return out
+
+
+def _assistant_message(response: Any) -> Message:
+ """Convert a non-streaming response into a Message for re-submission."""
+ msg = getattr(response, "message", None) or (
+ response.get("message") if isinstance(response, dict) else None
+ )
+ if isinstance(msg, dict):
+ return Message(**msg)
+ if msg is not None:
+ return cast(Message, msg)
+ return Message(role="assistant", content="")
+
+
+def chat_with_tools(
+ messages: list[Message],
+ model: str,
+ *,
+ think: bool | str | None = None,
+ registry: ToolRegistry | None = None,
+ host: str | None = None,
+) -> Iterator[dict[str, Any]]:
+ """Run a multi-round tool-calling chat and yield progress events.
+
+ Events are dicts with a ``type`` key:
+
+ - ``{"type": "tool_start", "name": str, "arguments": dict}``
+ - ``{"type": "tool_end", "name": str, "result": str}``
+ - ``{"type": "delta", "content": str}``
+ - ``{"type": "thinking", "content": str}``
+ - ``{"type": "done"}``
+ - ``{"type": "error", "message": str}``
+
+ The final assistant message is streamed token-by-token via ``delta`` events.
+ """
+ raw_client = get_client(host)
+ reg = registry or default_registry()
+ tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables]
+
+ rounds = 0
+ while rounds < MAX_TOOL_ROUNDS:
+ rounds += 1
+ try:
+ resp = cast(Any, raw_client).chat(
+ model=model,
+ messages=messages,
+ tools=tools,
+ think=think,
+ stream=False,
+ )
+ except Exception as exc: # noqa: BLE001
+ yield {"type": "error", "message": str(exc)}
+ return
+
+ calls = _tool_calls(resp)
+ if not calls:
+ # No tool calls: stream the final answer for UI effect.
+ content = _assistant_content(resp)
+ thinking = _assistant_thinking(resp)
+ if thinking:
+ yield {"type": "thinking", "content": thinking}
+ if content:
+ yield {"type": "delta", "content": content}
+ yield {"type": "done"}
+ return
+
+ # Append the assistant message that requested tool calls.
+ messages.append(_assistant_message(resp))
+
+ # Execute each requested tool and append tool-role messages.
+ for call in calls:
+ name = call.get("name", "")
+ args = call.get("arguments", {})
+ yield {"type": "tool_start", "name": name, "arguments": args}
+ result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args))
+ yield {"type": "tool_end", "name": name, "result": result}
+ messages.append(Message(role="tool", tool_name=name, content=result))
+
+ # Exceeded tool rounds: ask for a final answer without tools.
+ try:
+ resp = cast(Any, raw_client).chat(
+ model=model,
+ messages=messages,
+ think=think,
+ stream=False,
+ )
+ except Exception as exc: # noqa: BLE001
+ yield {"type": "error", "message": str(exc)}
+ return
+ content = _assistant_content(resp)
+ thinking = _assistant_thinking(resp)
+ if thinking:
+ yield {"type": "thinking", "content": thinking}
+ if content:
+ yield {"type": "delta", "content": content}
+ yield {"type": "done"}
+
+
+def stream_chat_with_tools(
+ messages: list[Message],
+ model: str,
+ *,
+ think: bool | str | None = None,
+ registry: ToolRegistry | None = None,
+ host: str | None = None,
+) -> Iterator[dict[str, Any]]:
+ """Like ``chat_with_tools`` but the final answer is streamed from ollama.
+
+ Tool-call resolution rounds are performed in non-streaming mode; once no
+ further tool calls are requested the final response is streamed token by
+ token via ``delta`` events.
+ """
+ raw_client = get_client(host)
+ reg = registry or default_registry()
+ tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables]
+
+ rounds = 0
+ while rounds < MAX_TOOL_ROUNDS:
+ rounds += 1
+ try:
+ resp = cast(Any, raw_client).chat(
+ model=model,
+ messages=messages,
+ tools=tools,
+ think=think,
+ stream=False,
+ )
+ except Exception as exc: # noqa: BLE001
+ yield {"type": "error", "message": str(exc)}
+ return
+
+ calls = _tool_calls(resp)
+ if not calls:
+ # Stream the final answer by re-issuing the last messages.
+ # We already have the full non-streamed content; emit it as deltas
+ # in small chunks to simulate streaming for the UI.
+ thinking = _assistant_thinking(resp)
+ if thinking:
+ yield {"type": "thinking", "content": thinking}
+ content = _assistant_content(resp)
+ if content:
+ for chunk in _chunk_text(content, 64):
+ yield {"type": "delta", "content": chunk}
+ yield {"type": "done"}
+ return
+
+ messages.append(_assistant_message(resp))
+ for call in calls:
+ name = call.get("name", "")
+ args = call.get("arguments", {})
+ yield {"type": "tool_start", "name": name, "arguments": args}
+ result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args))
+ yield {"type": "tool_end", "name": name, "result": result}
+ messages.append(Message(role="tool", tool_name=name, content=result))
+
+ # Final attempt without tools.
+ try:
+ stream = cast(Any, raw_client).chat(
+ model=model,
+ messages=messages,
+ think=think,
+ stream=True,
+ )
+ for chunk in stream:
+ thinking = _assistant_thinking(chunk)
+ if thinking:
+ yield {"type": "thinking", "content": thinking}
+ content = _assistant_content(chunk)
+ if content:
+ yield {"type": "delta", "content": content}
+ except Exception as exc: # noqa: BLE001
+ yield {"type": "error", "message": str(exc)}
+ return
+ yield {"type": "done"}
+
+
+async def astream_chat_with_tools(
+ messages: list[Message],
+ model: str,
+ *,
+ think: bool | str | None = None,
+ registry: ToolRegistry | None = None,
+ host: 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)
+
+ def _next() -> dict[str, Any] | None:
+ try:
+ return next(gen)
+ except StopIteration:
+ return None
+
+ while True:
+ event = await anyio.to_thread.run_sync(_next)
+ if event is None:
+ break
+ yield event
+
+
+def _assistant_content(response: Any) -> str:
+ msg = getattr(response, "message", None) or (
+ response.get("message") if isinstance(response, dict) else None
+ )
+ if msg is None:
+ return ""
+ if isinstance(msg, dict):
+ return str(msg.get("content", "") or "")
+ return str(getattr(msg, "content", "") or "")
+
+
+def _assistant_thinking(response: Any) -> str:
+ msg = getattr(response, "message", None) or (
+ response.get("message") if isinstance(response, dict) else None
+ )
+ if msg is None:
+ return ""
+ if isinstance(msg, dict):
+ return str(msg.get("thinking", "") or "")
+ return str(getattr(msg, "thinking", "") or "")
+
+
+def _chunk_text(text: str, size: int) -> Iterator[str]:
+ """Yield successive chunks of ``text`` up to ``size`` characters."""
+ for i in range(0, len(text), size):
+ yield text[i : i + size]
diff --git a/src/ollama_web/main.py b/src/ollama_web/main.py
index c6836fc..4d540ea 100644
--- a/src/ollama_web/main.py
+++ b/src/ollama_web/main.py
@@ -1,11 +1,24 @@
"""Main entry point for the ollama-web application."""
+from __future__ import annotations
+
import sys
+import uvicorn
+
+from .config import settings
+
def main() -> int:
"""Run the application."""
- print("ollama-web starting...")
+ print(f"ollama-web starting on http://{settings.host}:{settings.port}")
+ print(f"Ollama host: {settings.ollama_host}")
+ uvicorn.run(
+ "ollama_web.app:app",
+ host=settings.host,
+ port=settings.port,
+ reload=False,
+ )
return 0
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py
new file mode 100644
index 0000000..2612279
--- /dev/null
+++ b/src/ollama_web/routes/chat.py
@@ -0,0 +1,72 @@
+"""SSE streaming chat endpoint."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncIterator
+from typing import Any
+
+from ollama import Message
+from starlette.requests import Request
+from starlette.responses import StreamingResponse
+
+
+def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]:
+ """Convert raw JSON message dicts into ``ollama.Message`` objects."""
+ out: list[Message] = []
+ for m in raw:
+ role = m.get("role", "user")
+ content = m.get("content", "")
+ name = m.get("name")
+ images = m.get("images")
+ kwargs: dict[str, Any] = {"role": role, "content": content}
+ if name:
+ kwargs["name"] = name
+ if images:
+ kwargs["images"] = images
+ out.append(Message(**kwargs))
+ return out
+
+
+async def _event_stream(request: Request, payload: dict[str, Any]) -> 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")
+
+ messages = _parse_messages(raw_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):
+ data = json.dumps(event, ensure_ascii=False)
+ yield f"data: {data}\n\n".encode()
+
+
+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"))
+ except Exception as exc:
+ logger.error("failed to parse request json: %s", exc)
+ data = json.dumps({"type": "error", "message": f"Invalid JSON: {exc}"}, ensure_ascii=False)
+ return StreamingResponse(
+ _single_event(data),
+ media_type="text/event-stream",
+ )
+ msg_count = len(payload.get("messages", []))
+ logger.info("chat request model=%s messages=%d", payload.get("model"), msg_count)
+ return StreamingResponse(
+ _event_stream(request, payload),
+ 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()
diff --git a/src/ollama_web/routes/models.py b/src/ollama_web/routes/models.py
new file mode 100644
index 0000000..477a44e
--- /dev/null
+++ b/src/ollama_web/routes/models.py
@@ -0,0 +1,23 @@
+"""API routes for model listing and health checks."""
+
+from __future__ import annotations
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+from ollama_web import llm
+
+
+async def get_models(request: Request) -> JSONResponse:
+ """Return available Ollama models as JSON."""
+ host = request.app.state.settings.ollama_host
+ models = llm.list_models(host=host)
+ return JSONResponse({"models": models})
+
+
+async def health(request: Request) -> JSONResponse:
+ """Report Ollama server reachability."""
+ host = request.app.state.settings.ollama_host
+ models = llm.list_models(host=host)
+ reachable = models is not None
+ return JSONResponse({"reachable": reachable, "models": models}) \ No newline at end of file
diff --git a/src/ollama_web/routes/pages.py b/src/ollama_web/routes/pages.py
new file mode 100644
index 0000000..8e1d084
--- /dev/null
+++ b/src/ollama_web/routes/pages.py
@@ -0,0 +1,27 @@
+"""Page routes rendering Jinja2 templates."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.templating import Jinja2Templates
+
+from ollama_web import llm
+
+_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
+templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
+
+
+async def index(request: Request) -> Response:
+ """Render the chat page with the available model list."""
+ host = request.app.state.settings.ollama_host
+ models = llm.list_models(host=host)
+ default_model = request.app.state.settings.default_model
+ return templates.TemplateResponse(
+ request,
+ "index.html",
+ {"models": models, "default_model": default_model},
+ media_type="text/html; charset=utf-8",
+ )
diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js
new file mode 100644
index 0000000..b2cd08b
--- /dev/null
+++ b/src/ollama_web/static/app.js
@@ -0,0 +1,264 @@
+"use strict";
+
+const chatEl = document.getElementById("chat");
+const formEl = document.getElementById("input-form");
+const inputEl = document.getElementById("input");
+const sendBtn = document.getElementById("send-btn");
+const modelSelect = document.getElementById("model-select");
+const thinkToggle = document.getElementById("think-toggle");
+const clearBtn = document.getElementById("clear-btn");
+const cancelBtn = document.getElementById("cancel-btn");
+
+let messages = [];
+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;
+ return el.outerHTML;
+}
+
+function renderMarkdown(text) {
+ if (typeof marked !== "undefined" && marked.parse) {
+ try {
+ const html = marked.parse(text);
+ if (typeof html === "string") return html;
+ } catch {
+ // fall through
+ }
+ }
+ return simpleMarkdownToHtml(text);
+}
+
+function highlightIn(el) {
+ if (typeof hljs !== "undefined" && hljs.highlightElement) {
+ el.querySelectorAll("pre code").forEach((block) => {
+ try {
+ hljs.highlightElement(block);
+ } catch {
+ /* ignore */
+ }
+ });
+ }
+}
+
+function addMessage(role) {
+ const div = document.createElement("div");
+ div.className = `message ${role}`;
+ const roleEl = document.createElement("div");
+ roleEl.className = "role";
+ roleEl.textContent = role === "user" ? "あなた" : "アシスタント";
+ const body = document.createElement("div");
+ body.className = "body";
+ div.append(roleEl, body);
+ chatEl.appendChild(div);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ return body;
+}
+
+function addToolBubble(name, args) {
+ const div = document.createElement("div");
+ div.className = "tool";
+ const label = (TOOLS.find((t) => t.name === name) || { label: name }).label;
+ const status = document.createElement("span");
+ status.className = "status";
+ status.textContent = `${label} を実行中…`;
+ div.appendChild(status);
+ if (args && Object.keys(args).length) {
+ const a = document.createElement("div");
+ a.className = "args";
+ a.textContent = JSON.stringify(args, null, 2);
+ div.appendChild(a);
+ }
+ const result = document.createElement("div");
+ result.className = "result";
+ div.appendChild(result);
+ chatEl.appendChild(div);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ return { div, status, result };
+}
+
+function addThinkingBubble() {
+ const det = document.createElement("details");
+ det.className = "thinking";
+ const sum = document.createElement("summary");
+ sum.textContent = "思考プロセス";
+ const body = document.createElement("div");
+ det.append(sum, body);
+ chatEl.appendChild(det);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ return body;
+}
+
+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));
+ }
+ }
+ if (dataLines.length) {
+ events.push(dataLines.join(""));
+ }
+ }
+ return events;
+}
+
+async function send() {
+ const text = inputEl.value.trim();
+ if (!text) return;
+ inputEl.value = "";
+ sendBtn.disabled = true;
+ cancelBtn.disabled = false;
+ abortController = new AbortController();
+
+ messages.push({ role: "user", content: text });
+ const userBody = addMessage("user");
+ userBody.innerHTML = renderMarkdown(text);
+ highlightIn(userBody);
+
+ const assistantBody = addMessage("assistant");
+ assistantBody.textContent = "…";
+ let assistantText = "";
+ let thinkingBody = null;
+ const pendingTools = [];
+
+ const model = modelSelect.value;
+ const think = thinkToggle.checked;
+
+ const requestBody = JSON.stringify({ model, messages, think });
+ let res;
+ try {
+ res = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: requestBody,
+ signal: abortController.signal,
+ });
+ } catch (err) {
+ assistantBody.textContent =
+ err.name === "AbortError" ? "生成を停止しました" : `エラー: ${err.message}`;
+ abortController = null;
+ cancelBtn.disabled = true;
+ sendBtn.disabled = false;
+ return;
+ }
+
+ if (!res.ok || !res.body) {
+ assistantBody.textContent = `エラー: ${res.status}`;
+ abortController = null;
+ cancelBtn.disabled = true;
+ sendBtn.disabled = false;
+ return;
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+
+ const endIndex = buffer.lastIndexOf("\n\n");
+ if (endIndex === -1) continue;
+ const chunk = buffer.slice(0, endIndex);
+ buffer = buffer.slice(endIndex + 2);
+
+ for (const payload of parseSseEvent(chunk)) {
+ if (!payload.trim()) continue;
+ let ev;
+ try {
+ ev = JSON.parse(payload);
+ } catch (e) {
+ console.warn("failed to parse SSE payload", payload, e);
+ continue;
+ }
+
+ if (ev.type === "tool_start") {
+ const bubble = addToolBubble(ev.name, ev.arguments);
+ pendingTools.push(bubble);
+ } else if (ev.type === "tool_end") {
+ const b = pendingTools.shift();
+ if (b) {
+ b.status.textContent = `${b.status.textContent.replace("実行中…", "完了")}`;
+ b.result.textContent = (ev.result || "").slice(0, 2000);
+ }
+ } else if (ev.type === "thinking") {
+ if (!thinkingBody) thinkingBody = addThinkingBubble();
+ thinkingBody.textContent += ev.content;
+ chatEl.scrollTop = chatEl.scrollHeight;
+ } else if (ev.type === "delta") {
+ assistantText += ev.content;
+ assistantBody.innerHTML = renderMarkdown(assistantText);
+ highlightIn(assistantBody);
+ chatEl.scrollTop = chatEl.scrollHeight;
+ } else if (ev.type === "error") {
+ assistantBody.innerHTML += `<span style="color:#f85149">エラー: ${ev.message}</span>`;
+ } else if (ev.type === "done") {
+ break;
+ }
+ }
+ }
+
+ if (buffer.trim()) {
+ for (const payload of parseSseEvent(buffer)) {
+ if (!payload.trim()) continue;
+ let ev;
+ try {
+ ev = JSON.parse(payload);
+ } catch {
+ continue;
+ }
+ if (ev.type === "delta") {
+ assistantText += ev.content;
+ }
+ }
+ if (assistantText) {
+ assistantBody.innerHTML = renderMarkdown(assistantText);
+ highlightIn(assistantBody);
+ }
+ }
+
+ if (!assistantText && !pendingTools.length && !thinkingBody) {
+ assistantBody.textContent = "(応答なし)";
+ }
+
+ if (assistantText) {
+ messages.push({ role: "assistant", content: assistantText });
+ }
+ abortController = null;
+ cancelBtn.disabled = true;
+ sendBtn.disabled = false;
+ inputEl.focus();
+}
+
+formEl.addEventListener("submit", (e) => {
+ e.preventDefault();
+ send();
+});
+
+inputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ send();
+ }
+});
+
+clearBtn.addEventListener("click", () => {
+ messages = [];
+ chatEl.innerHTML = "";
+});
+
+cancelBtn.addEventListener("click", () => {
+ if (abortController) {
+ abortController.abort();
+ }
+});
diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css
new file mode 100644
index 0000000..ece20c1
--- /dev/null
+++ b/src/ollama_web/static/style.css
@@ -0,0 +1,149 @@
+:root {
+ --bg: #0d1117;
+ --panel: #161b22;
+ --border: #30363d;
+ --text: #e6edf3;
+ --muted: #8b949e;
+ --accent: #58a6ff;
+ --user-bg: #1f6feb22;
+ --assistant-bg: #161b22;
+ --tool-bg: #2d1117;
+ --think-bg: #1a2332;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: system-ui, "Segoe UI", sans-serif;
+ font-size: 15px;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 16px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+}
+
+header h1 {
+ font-size: 18px;
+ margin: 0;
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-left: auto;
+}
+
+select, button, input[type="checkbox"] {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 4px 8px;
+ font-size: 14px;
+}
+
+button { cursor: pointer; }
+button:hover { border-color: var(--accent); }
+
+.think-toggle { display: flex; align-items: center; gap: 4px; color: var(--muted); }
+
+main#chat {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.message {
+ max-width: 860px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 12px 14px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ line-height: 1.6;
+}
+
+.message.user { background: var(--user-bg); }
+.message.assistant { background: var(--assistant-bg); }
+.message .role { font-weight: 600; margin-bottom: 6px; color: var(--muted); }
+
+.message pre {
+ background: #010409;
+ border-radius: 8px;
+ padding: 10px;
+ overflow-x: auto;
+}
+
+.message code { font-family: "SFMono-Regular", Consolas, monospace; font-size: 13px; }
+.message p { margin: 8px 0; }
+.message ul, .message ol { padding-left: 22px; }
+
+.tool {
+ max-width: 860px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px dashed var(--border);
+ background: var(--tool-bg);
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.tool .name { color: var(--accent); font-weight: 600; }
+.tool .args, .tool .result { color: var(--muted); white-space: pre-wrap; word-break: break-word; }
+.tool details summary { cursor: pointer; color: var(--accent); }
+
+.thinking {
+ max-width: 860px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--think-bg);
+ color: var(--muted);
+ font-size: 13px;
+}
+.thinking summary { cursor: pointer; color: var(--accent); }
+
+form#input-form {
+ display: flex;
+ gap: 8px;
+ padding: 12px 16px;
+ background: var(--panel);
+ border-top: 1px solid var(--border);
+}
+
+textarea#input {
+ flex: 1;
+ resize: none;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px;
+ font-size: 15px;
+ font-family: inherit;
+}
+
+button#send-btn { padding: 0 20px; } \ No newline at end of file
diff --git a/src/ollama_web/templates/index.html b/src/ollama_web/templates/index.html
new file mode 100644
index 0000000..14bdf87
--- /dev/null
+++ b/src/ollama_web/templates/index.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="ja">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>ollama-web</title>
+ <link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" />
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js"></script>
+ <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>
+ </div>
+ </header>
+
+ <main id="chat"></main>
+
+ <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>
+
+ <script>
+ const TOOLS = [
+ { name: "web_search", label: "Web検索" },
+ { name: "scrape_url", label: "スクレイピング" },
+ ];
+ </script>
+ <script src="{{ url_for('static', path='app.js') }}"></script>
+</body>
+</html>
diff --git a/src/ollama_web/tools/__init__.py b/src/ollama_web/tools/__init__.py
new file mode 100644
index 0000000..c034c94
--- /dev/null
+++ b/src/ollama_web/tools/__init__.py
@@ -0,0 +1 @@
+"""Tool package for ollama-web.""" \ No newline at end of file
diff --git a/src/ollama_web/tools/registry.py b/src/ollama_web/tools/registry.py
new file mode 100644
index 0000000..92b321c
--- /dev/null
+++ b/src/ollama_web/tools/registry.py
@@ -0,0 +1,81 @@
+"""Registry of tools exposed to the LLM."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass, field
+
+from .scrape import scrape_url
+from .search import web_search
+
+
+@dataclass
+class ToolRegistry:
+ """A simple registry mapping tool names to callable implementations."""
+
+ _tools: dict[str, Callable[..., object]] = field(default_factory=dict)
+
+ def register(self, func: Callable[..., object]) -> Callable[..., object]:
+ """Register a callable under its ``__name__``."""
+ self._tools[func.__name__] = func
+ return func
+
+ @property
+ def names(self) -> list[str]:
+ return list(self._tools.keys())
+
+ @property
+ def callables(self) -> list[Callable[..., object]]:
+ return list(self._tools.values())
+
+ def get(self, name: str) -> Callable[..., object] | None:
+ return self._tools.get(name)
+
+ def execute(self, name: str, arguments: dict[str, object] | str) -> str:
+ """Execute a named tool and return a JSON-encoded string result.
+
+ Args:
+ name: The tool function name.
+ arguments: Either a dict of keyword arguments or a JSON string that
+ decodes to one.
+
+ Returns:
+ A JSON string representation of the tool's return value, or an error
+ object when the tool is unknown or raises.
+ """
+ func = self.get(name)
+ if func is None:
+ return json.dumps({"error": f"Unknown tool: {name}"})
+
+ if isinstance(arguments, str):
+ try:
+ arguments = json.loads(arguments) if arguments else {}
+ except json.JSONDecodeError:
+ return json.dumps({"error": f"Invalid JSON arguments for {name}: {arguments}"})
+
+ try:
+ result = func(**arguments)
+ except Exception as exc: # noqa: BLE001
+ return json.dumps({"error": f"{type(exc).__name__}: {exc}"})
+
+ if isinstance(result, str):
+ return result
+ try:
+ return json.dumps(result, ensure_ascii=False, default=str)
+ except (TypeError, ValueError):
+ return str(result)
+
+
+def default_registry() -> ToolRegistry:
+ """Build a registry pre-loaded with the built-in tools."""
+ reg = ToolRegistry()
+ reg.register(web_search)
+ reg.register(scrape_url)
+ return reg
+
+
+def all_tool_callables(registry: ToolRegistry | None = None) -> Sequence[Callable[..., object]]:
+ """Return the list of callables suitable for ``ollama`` ``tools`` argument."""
+ reg = registry or default_registry()
+ return reg.callables \ No newline at end of file
diff --git a/src/ollama_web/tools/scrape.py b/src/ollama_web/tools/scrape.py
new file mode 100644
index 0000000..d20956d
--- /dev/null
+++ b/src/ollama_web/tools/scrape.py
@@ -0,0 +1,59 @@
+"""URL scraping tool that fetches a page and extracts readable text."""
+
+from __future__ import annotations
+
+import httpx
+from bs4 import BeautifulSoup
+
+from ..config import settings
+
+
+def scrape_url(url: str, max_chars: int | None = None) -> str:
+ """Fetch a web page and return its main text content.
+
+ Scripts, styles and other non-content elements are removed before
+ collapsing whitespace. The returned text is truncated to ``max_chars``.
+
+ Args:
+ url: The absolute URL of the page to scrape.
+ max_chars: Maximum number of characters to return. Defaults to the value
+ in settings.
+
+ Returns:
+ The extracted plain text of the page, truncated to ``max_chars``. If the
+ request fails a short error description is returned instead.
+ """
+ if not url or not url.strip():
+ return "Invalid URL."
+
+ limit = max_chars if max_chars is not None and max_chars > 0 else settings.scrape_max_chars
+ headers = {"User-Agent": "ollama-web/0.1 (+https://github.com/local)"}
+
+ try:
+ with httpx.Client(
+ timeout=settings.scrape_timeout,
+ follow_redirects=True,
+ headers=headers,
+ ) as client:
+ resp = client.get(url)
+ resp.raise_for_status()
+ html = resp.text
+ except Exception as exc: # noqa: BLE001
+ return f"Failed to fetch {url}: {exc}"
+
+ soup = BeautifulSoup(html, "html.parser")
+
+ # Remove non-content tags.
+ for tag in soup(["script", "style", "noscript", "template", "head", "nav", "footer", "aside"]):
+ tag.decompose()
+
+ # Prefer article/main content when available.
+ container = soup.find("article") or soup.find("main") or soup
+ text = container.get_text(separator="\n")
+ # Collapse blank lines and trailing whitespace per line.
+ lines = [ln.strip() for ln in text.splitlines()]
+ cleaned = "\n".join(ln for ln in lines if ln)
+
+ if len(cleaned) > limit:
+ cleaned = cleaned[:limit].rsplit(" ", 1)[0] + "\n…[truncated]"
+ return cleaned or "No readable text content found." \ No newline at end of file
diff --git a/src/ollama_web/tools/search.py b/src/ollama_web/tools/search.py
new file mode 100644
index 0000000..0af2a3e
--- /dev/null
+++ b/src/ollama_web/tools/search.py
@@ -0,0 +1,45 @@
+"""Web search tool backed by DuckDuckGo (ddgs)."""
+
+from __future__ import annotations
+
+from ddgs import DDGS
+
+from ..config import settings
+
+
+def web_search(query: str, max_results: int | None = None) -> list[dict[str, str]]:
+ """Search the web using DuckDuckGo and return compact result summaries.
+
+ Args:
+ query: The search query string.
+ max_results: Maximum number of results to return (default from settings).
+
+ Returns:
+ A list of dictionaries with keys 'title', 'href' and 'body' for each
+ result. Returns an empty list if the search fails or no results are found.
+ """
+ if not query or not query.strip():
+ return []
+
+ limit = (
+ max_results
+ if max_results is not None and max_results > 0
+ else settings.search_max_results
+ )
+
+ results: list[dict[str, str]] = []
+ try:
+ with DDGS() as ddgs:
+ for item in ddgs.text(query, max_results=limit):
+ results.append(
+ {
+ "title": str(item.get("title", "")).strip(),
+ "href": str(item.get("href") or item.get("url") or "").strip(),
+ "body": str(item.get("body") or item.get("snippet") or "").strip(),
+ }
+ )
+ except Exception: # noqa: BLE001
+ # Swallow network/runtime errors so the chat loop can continue.
+ return []
+
+ return results[:limit]
diff --git a/tests/__init__.py b/tests/__init__.py
index 53a83b6..2b1df6a 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,11 +1 @@
-</content>
-</write_in_file><write_to_file>
-<path>tests/test_main.py</path>
-<content>"""Tests for ollama_web.main."""
-
-from ollama_web.main import main
-
-
-def test_main() -> None:
- """Test that main returns 0."""
- assert main() == 0 \ No newline at end of file
+"""Test package for ollama-web.""" \ No newline at end of file
diff --git a/tests/test_tools.py b/tests/test_tools.py
new file mode 100644
index 0000000..897c597
--- /dev/null
+++ b/tests/test_tools.py
@@ -0,0 +1,100 @@
+"""Tests for tool registry and built-in tools."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import MagicMock, patch
+
+from ollama_web.tools.registry import ToolRegistry, default_registry
+
+
+def test_registry_register_and_execute():
+ reg = ToolRegistry()
+
+ def add(a: int, b: int) -> int:
+ """Add two numbers.
+
+ Args:
+ a: First number.
+ b: Second number.
+
+ Returns:
+ The sum.
+ """
+ return a + b
+
+ reg.register(add)
+ assert reg.get("add") is add
+ result = reg.execute("add", {"a": 2, "b": 3})
+ assert result == "5"
+
+
+def test_registry_execute_unknown():
+ reg = ToolRegistry()
+ out = reg.execute("nonexistent", {})
+ assert "error" in json.loads(out)
+
+
+def test_registry_execute_invalid_json_args():
+ reg = ToolRegistry()
+
+ def echo(x: str) -> str:
+ return x
+
+ reg.register(echo)
+ out = reg.execute("echo", "{not json")
+ assert "error" in json.loads(out)
+
+
+def test_registry_execute_handles_exceptions():
+ reg = ToolRegistry()
+
+ def boom() -> None:
+ raise ValueError("boom")
+
+ reg.register(boom)
+ out = reg.execute("boom", {})
+ assert "error" in json.loads(out)
+
+
+def test_default_registry_has_tools():
+ reg = default_registry()
+ assert "web_search" in reg.names
+ assert "scrape_url" in reg.names
+
+
+def test_web_search_empty_query():
+ from ollama_web.tools.search import web_search
+
+ assert web_search("") == []
+
+
+def test_web_search_mocked():
+ from ollama_web.tools import search as search_mod
+
+ fake = [{"title": "T", "href": "http://x", "body": "B"}]
+
+ class FakeDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return None
+
+ def text(self, q, max_results=5):
+ return fake
+
+ monkey = MagicMock()
+ monkey.DDGS = FakeDDGS
+ with patch.object(search_mod, "DDGS", FakeDDGS):
+ from ollama_web.tools.search import web_search
+
+ result = web_search("test", max_results=1)
+ assert len(result) == 1
+ assert result[0]["title"] == "T"
+
+
+def test_scrape_url_empty():
+ from ollama_web.tools.scrape import scrape_url
+
+ assert scrape_url("") == "Invalid URL." \ No newline at end of file