diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ollama_web/llm.py | 106 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 67 | ||||
| -rw-r--r-- | src/ollama_web/static/style.css | 50 |
3 files changed, 152 insertions, 71 deletions
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py index fbe043a..92f4ce5 100644 --- a/src/ollama_web/llm.py +++ b/src/ollama_web/llm.py @@ -44,6 +44,11 @@ def _tool_calls(response: Any) -> list[dict[str, Any]]: msg = getattr(response, "message", None) or ( response.get("message") if isinstance(response, dict) else None ) + return _tool_calls_from_message(msg) + + +def _tool_calls_from_message(msg: Any) -> list[dict[str, Any]]: + """Extract tool calls from a message object/dict.""" if msg is None: return [] calls = getattr(msg, "tool_calls", None) if not isinstance(msg, dict) else msg.get("tool_calls") @@ -172,11 +177,11 @@ def stream_chat_with_tools( registry: ToolRegistry | None = None, host: str | None = None, ) -> Iterator[dict[str, Any]]: - """Like ``chat_with_tools`` but the final answer is streamed from ollama. + """Run a tool-calling chat loop and yield real streaming events 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. + The final assistant answer is streamed token-by-token. Tool and thinking + events are emitted inline so the UI can render them inside the assistant + message. """ raw_client = get_client(host) reg = registry or default_registry() @@ -185,43 +190,60 @@ def stream_chat_with_tools( rounds = 0 while rounds < MAX_TOOL_ROUNDS: rounds += 1 + full_message: dict[str, Any] = {"role": "assistant", "content": "", "thinking": ""} + tool_calls: list[dict[str, Any]] = [] try: - resp = cast(Any, raw_client).chat( + stream = cast(Any, raw_client).chat( model=model, messages=messages, tools=tools, think=think, - stream=False, + stream=True, ) 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) + for chunk in stream: + thinking = _assistant_thinking(chunk) if thinking: + full_message.setdefault("thinking", "") + full_message["thinking"] += thinking yield {"type": "thinking", "content": thinking} - content = _assistant_content(resp) + content = _assistant_content(chunk) if content: - for chunk in _chunk_text(content, 64): - yield {"type": "delta", "content": chunk} + full_message["content"] += content + yield {"type": "delta", "content": content} + + calls = _tool_calls_from_message(getattr(chunk, "message", None)) + for c in calls: + # Avoid duplicates from repeated chunks containing the same call. + if c not in tool_calls: + tool_calls.append(c) + + # Reconstruct the assistant message including any tool_calls so the + # next round can see them. + if tool_calls: + full_message["tool_calls"] = tool_calls + msg = _assistant_message_chunk(full_message) + if msg is not None: + messages.append(msg) + + if not tool_calls: yield {"type": "done"} return - messages.append(_assistant_message(resp)) - for call in calls: + for call in tool_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)) + 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. + # Exceeded tool rounds: ask for a final answer without tools. try: stream = cast(Any, raw_client).chat( model=model, @@ -229,16 +251,17 @@ def stream_chat_with_tools( 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 + + 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} yield {"type": "done"} @@ -294,3 +317,34 @@ 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] + + +def _assistant_message_chunk(full_message: dict[str, Any]) -> Message | None: + """Build an assistant ``Message`` from the accumulated streamed message. + + ``tool_calls`` are expected to be already accumulated into ``full_message``. + If the message is empty, ``None`` is returned. + """ + content = full_message.get("content", "") + thinking = full_message.get("thinking", "") + calls = full_message.get("tool_calls", []) + + if not content and not thinking and not calls: + return None + + kwargs: dict[str, Any] = {"role": "assistant", "content": content} + if thinking: + kwargs["thinking"] = thinking + if calls: + # Convert plain dicts into ollama ToolCall objects. + tc_list: list[Message.ToolCall] = [] + for c in calls: + name = c.get("name", "") + args = c.get("arguments", {}) + tc_list.append( + Message.ToolCall( + function=Message.ToolCall.Function(name=name, arguments=args) + ) + ) + kwargs["tool_calls"] = tc_list + return Message(**kwargs) diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index b2cd08b..845a71f 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -53,41 +53,46 @@ function addMessage(role) { const body = document.createElement("div"); body.className = "body"; div.append(roleEl, body); + let extras = null; + if (role === "assistant") { + extras = document.createElement("div"); + extras.className = "extras"; + div.appendChild(extras); + } chatEl.appendChild(div); chatEl.scrollTop = chatEl.scrollHeight; - return body; + return { body, extras }; } -function addToolBubble(name, args) { - const div = document.createElement("div"); - div.className = "tool"; +function addToolBubble(parentExtras, name, args) { + const det = document.createElement("details"); + det.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); + const sum = document.createElement("summary"); + sum.textContent = `${label} を実行中…`; + det.appendChild(sum); if (args && Object.keys(args).length) { const a = document.createElement("div"); a.className = "args"; a.textContent = JSON.stringify(args, null, 2); - div.appendChild(a); + det.appendChild(a); } const result = document.createElement("div"); result.className = "result"; - div.appendChild(result); - chatEl.appendChild(div); + det.appendChild(result); + parentExtras.appendChild(det); chatEl.scrollTop = chatEl.scrollHeight; - return { div, status, result }; + return { det, sum, result }; } -function addThinkingBubble() { +function addThinkingBubble(parentExtras) { 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); + parentExtras.appendChild(det); chatEl.scrollTop = chatEl.scrollHeight; return body; } @@ -119,11 +124,13 @@ async function send() { abortController = new AbortController(); messages.push({ role: "user", content: text }); - const userBody = addMessage("user"); + const userBody = addMessage("user").body; userBody.innerHTML = renderMarkdown(text); highlightIn(userBody); - const assistantBody = addMessage("assistant"); + const assistantMsg = addMessage("assistant"); + const assistantBody = assistantMsg.body; + const assistantExtras = assistantMsg.extras; assistantBody.textContent = "…"; let assistantText = ""; let thinkingBody = null; @@ -162,6 +169,20 @@ async function send() { const decoder = new TextDecoder(); let buffer = ""; + // Remove the placeholder "…" as soon as real content arrives. + let placeholderCleared = false; + + function appendDelta(content) { + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + assistantText += content; + assistantBody.innerHTML = renderMarkdown(assistantText); + highlightIn(assistantBody); + chatEl.scrollTop = chatEl.scrollHeight; + } + while (true) { const { done, value } = await reader.read(); if (done) break; @@ -183,23 +204,21 @@ async function send() { } if (ev.type === "tool_start") { - const bubble = addToolBubble(ev.name, ev.arguments); + const bubble = addToolBubble(assistantExtras, 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.sum.textContent = b.sum.textContent.replace("実行中…", "完了"); b.result.textContent = (ev.result || "").slice(0, 2000); + chatEl.scrollTop = chatEl.scrollHeight; } } else if (ev.type === "thinking") { - if (!thinkingBody) thinkingBody = addThinkingBubble(); + if (!thinkingBody) thinkingBody = addThinkingBubble(assistantExtras); 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; + appendDelta(ev.content); } else if (ev.type === "error") { assistantBody.innerHTML += `<span style="color:#f85149">エラー: ${ev.message}</span>`; } else if (ev.type === "done") { @@ -261,4 +280,4 @@ cancelBtn.addEventListener("click", () => { if (abortController) { abortController.abort(); } -}); +});
\ No newline at end of file diff --git a/src/ollama_web/static/style.css b/src/ollama_web/static/style.css index ece20c1..0be42c2 100644 --- a/src/ollama_web/static/style.css +++ b/src/ollama_web/static/style.css @@ -97,34 +97,42 @@ main#chat { .message p { margin: 8px 0; } .message ul, .message ol { padding-left: 22px; } -.tool { - max-width: 860px; - width: 100%; - margin: 0 auto; - padding: 8px 12px; +.message .extras { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.message .extras details { border-radius: 8px; - border: 1px dashed var(--border); - background: var(--tool-bg); - color: var(--muted); + border: 1px solid var(--border); + padding: 6px 10px; font-size: 13px; + color: var(--muted); +} + +.message .extras details summary { + cursor: pointer; + color: var(--accent); + font-weight: 600; } -.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); } +.message .extras details .args, +.message .extras details .result { + white-space: pre-wrap; + word-break: break-word; + margin-top: 6px; +} -.thinking { - max-width: 860px; - width: 100%; - margin: 0 auto; - padding: 8px 12px; - border-radius: 8px; - border: 1px solid var(--border); +.message .extras details.tool { + background: var(--tool-bg); + border-style: dashed; +} + +.message .extras details.thinking { background: var(--think-bg); - color: var(--muted); - font-size: 13px; } -.thinking summary { cursor: pointer; color: var(--accent); } form#input-form { display: flex; |
