diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ollama_web/app.py | 46 | ||||
| -rw-r--r-- | src/ollama_web/config.py | 26 | ||||
| -rw-r--r-- | src/ollama_web/llm.py | 296 | ||||
| -rw-r--r-- | src/ollama_web/main.py | 15 | ||||
| -rw-r--r-- | src/ollama_web/routes/chat.py | 72 | ||||
| -rw-r--r-- | src/ollama_web/routes/models.py | 23 | ||||
| -rw-r--r-- | src/ollama_web/routes/pages.py | 27 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 264 | ||||
| -rw-r--r-- | src/ollama_web/static/style.css | 149 | ||||
| -rw-r--r-- | src/ollama_web/templates/index.html | 50 | ||||
| -rw-r--r-- | src/ollama_web/tools/__init__.py | 1 | ||||
| -rw-r--r-- | src/ollama_web/tools/registry.py | 81 | ||||
| -rw-r--r-- | src/ollama_web/tools/scrape.py | 59 | ||||
| -rw-r--r-- | src/ollama_web/tools/search.py | 45 |
14 files changed, 1153 insertions, 1 deletions
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] |
