summaryrefslogtreecommitdiff
path: root/src/ollama_web
diff options
context:
space:
mode:
Diffstat (limited to 'src/ollama_web')
-rw-r--r--src/ollama_web/llm.py106
-rw-r--r--src/ollama_web/static/app.js67
-rw-r--r--src/ollama_web/static/style.css50
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;