Initial Commit
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
title: Answer Sources
|
||||
author: owuitools
|
||||
version: 0.2.0
|
||||
description: Ends a tool-assisted answer with the URLs of the documents the tools actually read, when the answer names none of them.
|
||||
"""
|
||||
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# Across three graded rounds of forty prompts, the rule "end a tool-assisted
|
||||
# answer with the URL you drew on" was the one instruction that never landed
|
||||
# reliably: it lived in the system prompt (ignored), then in a `Sources:`
|
||||
# trailer on every tool result (lifted into the answer on roughly half of
|
||||
# the opportunities). The remaining half is the same failure each time - a
|
||||
# correct answer, read from the right page, that does not say so - and it is
|
||||
# the single largest bucket of lost points left.
|
||||
#
|
||||
# The model has the information; it just does not write it down. So this
|
||||
# filter writes it down. After the turn, it collects the documents the tools
|
||||
# report having READ (their `Sources:` trailers - fetched pages, Wikipedia
|
||||
# articles, RFCs, manual pages, package records) and, if the answer carries
|
||||
# none of those URLs and no sources line of its own, appends one.
|
||||
#
|
||||
# It never adds a search result: a URL that appeared in a `web_search`
|
||||
# listing was not opened, and attributing an answer to it would be exactly
|
||||
# the mislabelling the rounds penalise. Only tool outputs that end with a
|
||||
# `Sources:` line contribute, and only successful reads produce that line.
|
||||
#
|
||||
# The line goes into BOTH content and the last message item of output, for
|
||||
# the reason inline_tool_images.py records: Open WebUI renders from output
|
||||
# when it is present. Everything fails closed - on any surprise the message
|
||||
# is returned untouched.
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
_MARKER = "<!-- answer-sources -->"
|
||||
_SOURCES_LINE = re.compile(r"^Sources:\s*(.+?)\s*$", re.MULTILINE)
|
||||
_URL = re.compile(r"https?://[^\s<>\"')\]]+")
|
||||
_DOI = re.compile(r"\bdoi:\s*(10\.\d{4,9}/\S+)", re.IGNORECASE)
|
||||
# A line the model wrote itself that already serves as attribution. "Key
|
||||
# sources:", "Sources used:" and "**References:**" all count; a research
|
||||
# answer with its own labelled sources block got a second, worse one
|
||||
# appended under it because the label did not start with the word.
|
||||
_OWN_SOURCES = re.compile(
|
||||
r"^\s*(?:[*_#>\-\s]*)(?:[\w-]+\s+){0,2}(?:sources?|references?|citations?|bibliography)"
|
||||
r"(?:\s+(?:used|consulted|read|cited))?\s*[*_]*\s*[:\-–—]",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BODY_DOI = re.compile(r"\b10\.\d{4,9}/[^\s<>\"')\]]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _normalise(url: str) -> str:
|
||||
url = url.strip().rstrip(".,;:")
|
||||
url = re.sub(r"^https?://(www\.)?", "", url, flags=re.IGNORECASE)
|
||||
return url.rstrip("/").lower()
|
||||
|
||||
|
||||
def _documents_from(text: str) -> list[tuple[str, set[str]]]:
|
||||
"""One (url, aliases) per `Sources:` line in a tool result, in order.
|
||||
|
||||
A trailer reads `Sources: <CITE AS line with a DOI> · <page URL>`. That is
|
||||
one document, not two: counted as two it filled the cap twice as fast, and
|
||||
the doi.org form and the page form of the same paper were both listed. The
|
||||
page URL is what was read and is what gets shown; the DOI is kept as an
|
||||
alias so a bare doi: in the answer counts as naming it.
|
||||
"""
|
||||
docs: list[tuple[str, set[str]]] = []
|
||||
for match in _SOURCES_LINE.finditer(text or ""):
|
||||
urls, dois = [], []
|
||||
for entry in match.group(1).split(" · "):
|
||||
entry = " ".join(entry.split())
|
||||
urls += [u.rstrip(".,;:)") for u in _URL.findall(entry)]
|
||||
dois += [d.group(1).rstrip(".,;:)") for d in _DOI.finditer(entry)]
|
||||
for u in list(urls):
|
||||
if "doi.org/" in u.lower():
|
||||
dois.append(u.split("doi.org/", 1)[1])
|
||||
page = next((u for u in reversed(urls) if "doi.org/" not in u.lower()), None)
|
||||
shown = page or (urls[-1] if urls else None) or (f"https://doi.org/{dois[0]}" if dois else None)
|
||||
if shown is None:
|
||||
continue
|
||||
aliases = {_normalise(u) for u in urls} | {d.lower() for d in dois}
|
||||
docs.append((shown, aliases))
|
||||
return docs
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
priority: int = Field(default=10, description="Run after the other outlet filters.")
|
||||
enabled: bool = Field(default=True, description="Turn the filter off without deleting it.")
|
||||
max_sources: int = Field(
|
||||
default=8,
|
||||
description="Most documents to list, in reading order. A long research turn can "
|
||||
"read twenty pages; one document per Sources: line, so a paper's DOI and its page "
|
||||
"URL count once.",
|
||||
)
|
||||
label: str = Field(default="Sources", description="Label for the appended line.")
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def outlet(self, body: dict, __user__=None, __metadata__=None) -> dict:
|
||||
if not self.valves.enabled:
|
||||
return body
|
||||
try:
|
||||
messages = body.get("messages") or []
|
||||
target = None
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant" and message.get("output"):
|
||||
target = message
|
||||
break
|
||||
if target is None:
|
||||
return body
|
||||
|
||||
content = target.get("content") or ""
|
||||
if not content.strip() or _MARKER in content:
|
||||
return body
|
||||
|
||||
# Documents the tools read this turn, in the order they were read.
|
||||
entries: dict[str, str] = {}
|
||||
aliases: set[str] = set()
|
||||
for item in target.get("output") or []:
|
||||
if item.get("type") != "function_call_output":
|
||||
continue
|
||||
for part in item.get("output") or []:
|
||||
if part.get("type") not in ("input_text", "output_text", "text"):
|
||||
continue
|
||||
for url, names in _documents_from(part.get("text") or ""):
|
||||
key = _normalise(url)
|
||||
if key not in entries and not (names & aliases):
|
||||
entries[key] = url
|
||||
aliases |= names | {key}
|
||||
if not entries:
|
||||
return body
|
||||
|
||||
# The answer already attributes itself: it names one of the read
|
||||
# URLs or DOIs, or carries a sources line of its own. Leave it
|
||||
# alone - a second sources block under the model's own is
|
||||
# clutter, and the model's choice of which page to cite is not
|
||||
# ours to edit.
|
||||
lowered = content.lower()
|
||||
if any(key in lowered for key in entries):
|
||||
return body
|
||||
if any(d.lower() in aliases for d in _BODY_DOI.findall(content)):
|
||||
return body
|
||||
if _OWN_SOURCES.search(content):
|
||||
return body
|
||||
|
||||
urls = list(entries.values())[: max(1, self.valves.max_sources)]
|
||||
label = (self.valves.label or "Sources").strip()
|
||||
visible = "\n\n---\n" + f"{label}: " + " · ".join(urls)
|
||||
|
||||
target["content"] = content.rstrip() + visible + "\n" + _MARKER
|
||||
|
||||
output_items = target.get("output") or []
|
||||
message_items = [o for o in output_items if o.get("type") == "message"]
|
||||
if message_items:
|
||||
last = message_items[-1]
|
||||
parts = last.get("content") or []
|
||||
texts = [p for p in parts if p.get("type") == "output_text"]
|
||||
if texts:
|
||||
texts[-1]["text"] = (texts[-1].get("text") or "").rstrip() + visible
|
||||
else:
|
||||
parts.append({"type": "output_text", "text": visible.lstrip()})
|
||||
last["content"] = parts
|
||||
except Exception:
|
||||
return body
|
||||
return body
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
title: Inline Tool Images
|
||||
author: owuitools
|
||||
version: 0.1.0
|
||||
description: Shows images returned by tool calls (e.g. matlab_run figures) inline in the chat.
|
||||
"""
|
||||
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# When an OpenAPI tool server returns a result that IS a 'data:image/...'
|
||||
# URI, Open WebUI lifts it out and - on the native tool-calling path -
|
||||
# routes it to the MODEL as a vision input:
|
||||
#
|
||||
# utils/middleware.py
|
||||
# if file_item['type'] == 'image' and file_item['url'].startswith('data:'):
|
||||
# output_parts.append({'type': 'input_image', ...}) # LLM-only
|
||||
# else:
|
||||
# display_files.append(file_item) # user sees this
|
||||
#
|
||||
# So the model can see the picture and the user cannot. Only a NON-data
|
||||
# URL reaches the frontend. This filter closes that gap without touching
|
||||
# the tool server, the network policy, or the model: the image is already
|
||||
# sitting in the finished message, so we upload it through Open WebUI's
|
||||
# own helper - which stores it, links it to the message and hands back a
|
||||
# real file URL - and append that URL to the message as markdown.
|
||||
#
|
||||
# The markdown has to go into BOTH content and the last message item of
|
||||
# output. Open WebUI renders from output when it is present and falls
|
||||
# back to content only when it is not (get_output_text), so editing
|
||||
# content alone stores the image and shows nothing.
|
||||
#
|
||||
# Everything here fails closed. If an import moves in a future Open WebUI
|
||||
# release, or an upload fails, the message is returned untouched: no
|
||||
# picture is a nuisance, a corrupted answer is not.
|
||||
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Marker left in the appended block so a second pass over the same message
|
||||
# cannot upload the same image twice. Each upload mints a NEW url, so
|
||||
# checking for a previous url would never match.
|
||||
#
|
||||
# It goes in `content` ONLY. Open WebUI renders the `output` text, and its
|
||||
# markdown renderer does not hide HTML comments - the marker appeared under
|
||||
# the image as literal "<!-- inline-tool-images -->". content is not
|
||||
# rendered, so the guard can live there and stay invisible.
|
||||
_MARKER = "<!-- inline-tool-images -->"
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Turn the whole filter off without deleting it.",
|
||||
)
|
||||
max_images: int = Field(
|
||||
default=4,
|
||||
description="Most images to attach to one message. A runaway loop that plots on "
|
||||
"every iteration should not paste twenty figures into the chat.",
|
||||
)
|
||||
strip_base64_from_output: bool = Field(
|
||||
default=True,
|
||||
description="Remove the base64 copy from the stored message once it has been "
|
||||
"uploaded. The model has already seen it by this point, and leaving it behind "
|
||||
"keeps roughly 11 KB per figure in the chat record.",
|
||||
)
|
||||
caption: str = Field(
|
||||
default="figure",
|
||||
description="Alt text used for the appended images.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def outlet(
|
||||
self,
|
||||
body: dict,
|
||||
__request__=None,
|
||||
__user__=None,
|
||||
__metadata__=None,
|
||||
) -> dict:
|
||||
if not self.valves.enabled or __request__ is None:
|
||||
return body
|
||||
|
||||
try:
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.utils.files import get_image_url_from_base64
|
||||
except Exception:
|
||||
return body
|
||||
|
||||
try:
|
||||
messages = body.get("messages") or []
|
||||
target = None
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant" and message.get("output"):
|
||||
target = message
|
||||
break
|
||||
if target is None:
|
||||
return body
|
||||
|
||||
content = target.get("content") or ""
|
||||
output_items = target.get("output") or []
|
||||
if _MARKER in content or _MARKER in str(output_items):
|
||||
return body
|
||||
|
||||
# Which figure did each tool call ask for? A model that plots,
|
||||
# looks at the result, spots a problem and re-plots plainly means
|
||||
# the second one - but both images are sitting in the output, and
|
||||
# appending both showed the user a picture the model had already
|
||||
# rejected. Grouping by the requested filename separates a
|
||||
# REFINEMENT (same name, keep the last) from a genuinely second
|
||||
# figure (different name, keep both).
|
||||
figure_by_call = {}
|
||||
for item in output_items:
|
||||
if item.get("type") != "function_call":
|
||||
continue
|
||||
call_id = item.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
args = item.get("arguments")
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except Exception:
|
||||
args = {}
|
||||
if isinstance(args, dict):
|
||||
figure_by_call[call_id] = args.get("return_figure") or ""
|
||||
|
||||
# Keyed, insertion-ordered, last write wins.
|
||||
found_by_key = {}
|
||||
for item in output_items:
|
||||
if item.get("type") != "function_call_output":
|
||||
continue
|
||||
call_id = item.get("call_id")
|
||||
for part in item.get("output") or []:
|
||||
if part.get("type") != "input_image":
|
||||
continue
|
||||
url = part.get("image_url") or ""
|
||||
if not url.startswith("data:image/"):
|
||||
continue
|
||||
# Falling back to the call id keeps every image when the
|
||||
# filename cannot be resolved: showing one image too many
|
||||
# beats silently dropping the only one.
|
||||
key = figure_by_call.get(call_id) or "call:%s" % call_id
|
||||
found_by_key[key] = (part, url)
|
||||
|
||||
found = list(found_by_key.values())
|
||||
if not found:
|
||||
return body
|
||||
|
||||
user_id = (__user__ or {}).get("id")
|
||||
user = await Users.get_user_by_id(user_id) if user_id else None
|
||||
if user is None:
|
||||
return body
|
||||
|
||||
metadata = {
|
||||
"chat_id": body.get("chat_id"),
|
||||
"message_id": target.get("id"),
|
||||
}
|
||||
|
||||
urls = []
|
||||
for part, data_uri in found[: max(1, self.valves.max_images)]:
|
||||
try:
|
||||
url = await get_image_url_from_base64(__request__, data_uri, metadata, user)
|
||||
except Exception:
|
||||
url = None
|
||||
if not url:
|
||||
continue
|
||||
urls.append(url)
|
||||
if self.valves.strip_base64_from_output:
|
||||
part["image_url"] = url
|
||||
|
||||
if not urls:
|
||||
return body
|
||||
|
||||
caption = self.valves.caption or "figure"
|
||||
block = "\n".join(f"" for url in urls)
|
||||
visible = f"\n\n{block}"
|
||||
|
||||
target["content"] = content.rstrip() + visible + "\n" + _MARKER
|
||||
|
||||
# The one that actually shows: the visible answer is the last
|
||||
# message item in output, and everything before it is
|
||||
# reasoning and tool traffic.
|
||||
message_items = [o for o in output_items if o.get("type") == "message"]
|
||||
if message_items:
|
||||
last = message_items[-1]
|
||||
parts = last.get("content") or []
|
||||
texts = [p for p in parts if p.get("type") == "output_text"]
|
||||
if texts:
|
||||
texts[-1]["text"] = (texts[-1].get("text") or "").rstrip() + visible
|
||||
else:
|
||||
parts.append({"type": "output_text", "text": visible.lstrip()})
|
||||
last["content"] = parts
|
||||
except Exception:
|
||||
return body
|
||||
|
||||
return body
|
||||
@@ -0,0 +1,570 @@
|
||||
"""
|
||||
title: Live Progress Status
|
||||
author: owuitools
|
||||
version: 0.2.0
|
||||
description: Shows what the model is doing right now - processing the prompt, thinking, and each tool call as it is written.
|
||||
"""
|
||||
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# Until the first token arrives, Open WebUI shows nothing at all. On this
|
||||
# deployment the prompt is ~9.4k tokens of system prompt plus 20 tool
|
||||
# schemas, and a forced re-prefill takes many seconds, so "nothing at all"
|
||||
# is the most common thing on screen. Worse, the model spends ~70% of its
|
||||
# generated characters thinking, and tool calls are written token by token
|
||||
# into a block that is collapsed by default - so even once it IS producing
|
||||
# output, there is no visible signal of which phase it is in.
|
||||
#
|
||||
# This filter emits Open WebUI status events on phase CHANGES only:
|
||||
#
|
||||
# Processing prompt <- inlet, before the model is called
|
||||
# Thinking <- first reasoning delta
|
||||
# Thought: <opening of it> <- when a reasoning block ends
|
||||
# Calling web_search <- a tool call is being written
|
||||
# Writing response <- first content delta
|
||||
#
|
||||
# Together those read as a narrative of the whole turn, which is what makes
|
||||
# the collapsed tool blocks acceptable: the chain of thought and the tool
|
||||
# sequence stay visible without any tool input or output on screen.
|
||||
#
|
||||
# WHERE THE NUMBERS COME FROM
|
||||
#
|
||||
# The status line used to claim "prompt processed in N s" for a figure that
|
||||
# was really inlet-to-first-token wall clock: Open WebUI middleware, queue
|
||||
# wait and model load included. Graded runs put it 2-3x either side of
|
||||
# Ollama's own prefill for the same turn, so the one number on screen was
|
||||
# the one number that was wrong; 35 of 40 rubrics flagged it.
|
||||
#
|
||||
# Ollama's real figures are available, and this filter now reads them
|
||||
# instead of guessing. Open WebUI's Ollama->OpenAI conversion builds a
|
||||
# usage object on the final chunk of each round:
|
||||
#
|
||||
# utils/response.py convert_ollama_usage_to_openai()
|
||||
# 'prompt_eval_count', 'prompt_eval_duration', (nanoseconds)
|
||||
# 'eval_count', 'eval_duration', 'total_duration', 'load_duration'
|
||||
# utils/misc.py openai_chat_chunk_message_template()
|
||||
# template['usage'] = usage <- TOP LEVEL, beside 'choices'
|
||||
#
|
||||
# and stream filters are handed that chunk verbatim, before any
|
||||
# normalisation (middleware.py, filter_type='stream'). The same numbers
|
||||
# reach outlet on the stored message's 'usage', so outlet can fall back to
|
||||
# it - with one caveat that is worth knowing when the two disagree:
|
||||
# merge_usage() sums only input_tokens/output_tokens and keeps the LATEST
|
||||
# value of every provider-specific key, so a fallback prefill is the last
|
||||
# round's warm re-prefill, while the stream path deliberately keeps the
|
||||
# FIRST one, which is the cold prefill the user actually waited through.
|
||||
#
|
||||
# WHY THIS CANNOT AFFECT THE MODEL'S OUTPUT
|
||||
#
|
||||
# Status events travel over the websocket and are stored on a SIBLING key
|
||||
# of the message ('statusHistory'), never in 'content'. The payload sent to
|
||||
# the model is rebuilt from scratch by an explicit allowlist:
|
||||
#
|
||||
# utils/payload.py convert_messages_openai_to_ollama()
|
||||
# new_message = {'role': message['role']}
|
||||
# # then only: thinking, content, tool_calls, tool_call_id, images
|
||||
#
|
||||
# 'statusHistory' is not in that list, and the backend never references it
|
||||
# outside persistence. The token sequence is therefore byte-identical with
|
||||
# this filter on or off - no prompt change, no prefix-cache perturbation.
|
||||
# The one thing that does reach the chat record is the empty-answer note
|
||||
# below, and only on a turn that produced no answer to disturb.
|
||||
#
|
||||
# TWO SHARP EDGES IN THE STREAM HOOK, both load-bearing:
|
||||
#
|
||||
# 1. The return value REPLACES the chunk, and a FALSY return DROPS it:
|
||||
# data = wrap_item(JSONCodec.dumps(event)) if event else None
|
||||
# So stream() must return `event` on every single path. Returning None
|
||||
# would silently delete the model's output.
|
||||
# 2. Exceptions from a stream filter are re-raised by the caller
|
||||
# (utils/filter.py logs then `raise`), which would break the response
|
||||
# mid-flight. So the whole body is wrapped and always falls through to
|
||||
# `return event`.
|
||||
#
|
||||
# The module object is cached across requests (load_from_db=False for
|
||||
# stream), so per-request state must be keyed by message id rather than
|
||||
# held on self - six users share this instance.
|
||||
|
||||
import time
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Per-message progress state, keyed by message id. Bounded below so a
|
||||
# request whose outlet never runs (client disconnect, cancelled generation)
|
||||
# cannot leak entries forever.
|
||||
_STATE: dict[str, dict] = {}
|
||||
_MAX_TRACKED = 64
|
||||
|
||||
# Shown in the message itself when the turn ended with no answer text. One
|
||||
# graded run spent 140 s and 8,201 tokens repeating a single sentence inside
|
||||
# its reasoning block, emitted nothing, and the indicator said "Done" - so
|
||||
# the failure looked exactly like a success that had scrolled off screen.
|
||||
# 0.11.3's filter API is inlet/stream/request/outlet only, with no hook that
|
||||
# re-enters the chat pipeline, so telling the user to regenerate is the most
|
||||
# this filter can do.
|
||||
_EMPTY_NOTE = (
|
||||
"_No answer was produced: the model ended its turn while still reasoning. "
|
||||
"Regenerate to try again._"
|
||||
)
|
||||
|
||||
|
||||
def _new_state() -> dict:
|
||||
"""The per-message progress record. One factory, used by both creation
|
||||
sites - they were duplicated literals once and silently drifted."""
|
||||
return {
|
||||
"started": time.monotonic(),
|
||||
"phase": None,
|
||||
"announced": set(),
|
||||
"round": 0,
|
||||
"prompt_reported": False,
|
||||
"tools": 0,
|
||||
"reasoning": [],
|
||||
# Ollama's own figures, filled in from the usage chunk. prefill is
|
||||
# first-wins (the cold one); generated tokens accumulate over every
|
||||
# round of a tool-calling turn.
|
||||
"prefill_s": None,
|
||||
"prompt_tokens": None,
|
||||
"gen_tokens": 0,
|
||||
}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
while len(_STATE) > _MAX_TRACKED:
|
||||
# dicts are insertion-ordered, so this drops the oldest.
|
||||
_STATE.pop(next(iter(_STATE)), None)
|
||||
|
||||
|
||||
def _number(value) -> float | None:
|
||||
"""Usage values arrive as whatever the provider sent. Anything that is
|
||||
not a real number (None, 'N/A', a string) is treated as absent."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Turn the whole filter off without deleting it.",
|
||||
)
|
||||
show_prompt_processing: bool = Field(
|
||||
default=True,
|
||||
description="Show 'Processing prompt' before the first token. This is the phase "
|
||||
"that currently shows nothing at all, and on a cold prompt it is the longest.",
|
||||
)
|
||||
show_thinking: bool = Field(
|
||||
default=True,
|
||||
description="Show 'Thinking' once the model starts producing reasoning tokens.",
|
||||
)
|
||||
show_tool_calls: bool = Field(
|
||||
default=True,
|
||||
description="Show 'Calling <tool>' as each tool call is written. Tool calls are "
|
||||
"still counted for the final line when this is off.",
|
||||
)
|
||||
report_prompt_time: bool = Field(
|
||||
default=True,
|
||||
description="Append timing to the status line: how long the first token took to "
|
||||
"arrive, and - on the final line, from Ollama's own counters - how long the "
|
||||
"prefill took and how many tokens went in and came out.",
|
||||
)
|
||||
thought_excerpt_chars: int = Field(
|
||||
default=160,
|
||||
description="When a block of reasoning ends, post an excerpt of it as a status so "
|
||||
"the timeline reads as a narrative of what the model was doing. 0 disables it. "
|
||||
"The renderer clamps a status to one line, so values much above ~180 are wasted.",
|
||||
)
|
||||
clear_when_done: bool = Field(
|
||||
default=False,
|
||||
description="Hide the status line entirely once the response finishes. Off by "
|
||||
"default so the finished message keeps a short record of what it did, which "
|
||||
"matches how Open WebUI's own built-in tools behave.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
# ---------------------------------------------------------------- utils
|
||||
|
||||
async def _emit(self, emitter, description: str, *, done: bool = False, hidden: bool = False) -> None:
|
||||
if emitter is None:
|
||||
return
|
||||
try:
|
||||
await emitter(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": description, "done": done, "hidden": hidden},
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# A status line is a nicety; never let it disturb the response.
|
||||
pass
|
||||
|
||||
def _message_id(self, metadata) -> str | None:
|
||||
if isinstance(metadata, dict):
|
||||
return metadata.get("message_id") or metadata.get("chat_id")
|
||||
return None
|
||||
|
||||
def _state(self, message_id: str | None) -> dict | None:
|
||||
if not message_id:
|
||||
return None
|
||||
state = _STATE.get(message_id)
|
||||
if state is None:
|
||||
# stream() can legitimately run without inlet having recorded
|
||||
# anything, so build a usable state rather than giving up.
|
||||
state = _new_state()
|
||||
_STATE[message_id] = state
|
||||
_prune()
|
||||
return state
|
||||
|
||||
def _record_usage(self, state: dict, usage: dict) -> None:
|
||||
"""Take Ollama's own prefill and token counts off a usage chunk.
|
||||
|
||||
Prefill is first-wins because a tool-calling turn prefills once per
|
||||
round and only the first one is the wait the user sat through; every
|
||||
later round is served warm off the prefix cache. Generated tokens
|
||||
accumulate, because all of them were generated on this turn.
|
||||
"""
|
||||
if state["prefill_s"] is None:
|
||||
duration = _number(usage.get("prompt_eval_duration"))
|
||||
if duration and duration > 0:
|
||||
state["prefill_s"] = duration / 1e9
|
||||
if state["prompt_tokens"] is None:
|
||||
count = _number(usage.get("prompt_eval_count"))
|
||||
if count and count > 0:
|
||||
state["prompt_tokens"] = int(count)
|
||||
generated = _number(usage.get("eval_count"))
|
||||
if generated and generated > 0:
|
||||
state["gen_tokens"] += int(generated)
|
||||
|
||||
def _final_usage(self, state: dict | None, message: dict | None) -> tuple:
|
||||
"""(prefill_s, prompt_tokens, gen_tokens) for the final line.
|
||||
|
||||
The stream numbers are preferred; the stored message's usage is the
|
||||
fallback for the paths where stream() never saw a usage chunk - a
|
||||
non-streaming response, or a filter loaded after the stream began.
|
||||
"""
|
||||
prefill_s = state["prefill_s"] if state else None
|
||||
prompt_tokens = state["prompt_tokens"] if state else None
|
||||
gen_tokens = state["gen_tokens"] if state else 0
|
||||
|
||||
usage = message.get("usage") if isinstance(message, dict) else None
|
||||
if isinstance(usage, dict):
|
||||
if prefill_s is None:
|
||||
duration = _number(usage.get("prompt_eval_duration"))
|
||||
if duration and duration > 0:
|
||||
prefill_s = duration / 1e9
|
||||
if prompt_tokens is None:
|
||||
count = _number(usage.get("prompt_eval_count")) or _number(usage.get("prompt_tokens"))
|
||||
if count and count > 0:
|
||||
prompt_tokens = int(count)
|
||||
if not gen_tokens:
|
||||
# output_tokens is the one Open WebUI sums across rounds;
|
||||
# the Ollama-native keys hold the last round only.
|
||||
generated = (
|
||||
_number(usage.get("output_tokens"))
|
||||
or _number(usage.get("completion_tokens"))
|
||||
or _number(usage.get("eval_count"))
|
||||
)
|
||||
if generated and generated > 0:
|
||||
gen_tokens = int(generated)
|
||||
|
||||
return prefill_s, prompt_tokens, gen_tokens
|
||||
|
||||
def _usage_clause(self, prefill_s, prompt_tokens, gen_tokens) -> str:
|
||||
"""The measured half of the final line. Empty when nothing was
|
||||
measured - an absent clause is honest, an invented one is not."""
|
||||
if not self.valves.report_prompt_time:
|
||||
return ""
|
||||
parts = []
|
||||
if prefill_s is not None:
|
||||
if prompt_tokens:
|
||||
parts.append(f"prefill {prefill_s:.1f} s for {prompt_tokens:,} tokens")
|
||||
else:
|
||||
parts.append(f"prefill {prefill_s:.1f} s")
|
||||
if gen_tokens:
|
||||
parts.append(f"{gen_tokens:,} generated")
|
||||
return "".join(f" · {part}" for part in parts)
|
||||
|
||||
async def _flush_thought(self, state: dict, emitter) -> None:
|
||||
"""Post an excerpt of the reasoning block that just ended, then drop it.
|
||||
|
||||
This is what makes the collapsed view usable: the tool payloads stay
|
||||
shut, but the status timeline still reads as a narrative of what the
|
||||
model was thinking between calls. The buffer is cleared either way, so
|
||||
a disabled excerpt cannot accumulate text for the life of the request.
|
||||
|
||||
The excerpt shows both ends of the block, not just the opening. Head
|
||||
truncation once surfaced "…I should verify with actual execution since
|
||||
I have tools. Actually, I" - cutting the sentence exactly where the
|
||||
model reversed itself, so a user watching live would have believed it
|
||||
was about to run code it had in fact just decided not to run. The
|
||||
conclusion of a thought lives at its end.
|
||||
"""
|
||||
buffered = state["reasoning"]
|
||||
state["reasoning"] = []
|
||||
limit = self.valves.thought_excerpt_chars
|
||||
if limit <= 0 or not buffered:
|
||||
return
|
||||
# Collapse to a single line - the renderer clamps to one line anyway,
|
||||
# and newlines in a status read as ragged whitespace.
|
||||
text = " ".join("".join(buffered).split())
|
||||
if not text:
|
||||
return
|
||||
if len(text) > limit:
|
||||
head_budget = max(1, int(limit * 0.55))
|
||||
tail_budget = max(1, int(limit * 0.40))
|
||||
# Prefer breaking at a word boundary near each edge.
|
||||
cut = text.rfind(" ", int(head_budget * 0.6), head_budget)
|
||||
head = text[: cut if cut != -1 else head_budget].rstrip()
|
||||
start = len(text) - tail_budget
|
||||
space = text.find(" ", start)
|
||||
if space != -1 and space + 1 < len(text):
|
||||
start = space + 1
|
||||
text = f"{head} … {text[start:].lstrip()}"
|
||||
await self._emit(emitter, f"Thought: {text}", done=True)
|
||||
|
||||
def _suffix(self, state: dict) -> str:
|
||||
"""Report, once, how long the first token took to arrive.
|
||||
|
||||
Deliberately not called prefill: this is inlet-to-first-token wall
|
||||
clock and includes Open WebUI's own work, the queue and any model
|
||||
load. The real prefill is not known until the usage chunk at the end
|
||||
of the round, and it goes on the final line instead.
|
||||
"""
|
||||
if state["prompt_reported"] or not self.valves.report_prompt_time:
|
||||
return ""
|
||||
state["prompt_reported"] = True
|
||||
elapsed = time.monotonic() - state["started"]
|
||||
if elapsed < 0.5:
|
||||
return ""
|
||||
return f" · first token after {elapsed:.1f} s"
|
||||
|
||||
def _mark_empty(self, message: dict) -> None:
|
||||
"""Leave a visible note on a turn that produced no answer text.
|
||||
|
||||
Both places, as inline_tool_images.py does it for its images: Open
|
||||
WebUI renders `output` when it is present and falls back to `content`
|
||||
only when it is not (misc.py get_output_text), so writing content
|
||||
alone stores the note and shows nothing.
|
||||
"""
|
||||
content = message.get("content") or ""
|
||||
message["content"] = (content.rstrip() + "\n\n" + _EMPTY_NOTE).lstrip()
|
||||
|
||||
output_items = message.get("output")
|
||||
if not isinstance(output_items, list):
|
||||
return
|
||||
message_items = [
|
||||
item for item in output_items if isinstance(item, dict) and item.get("type") == "message"
|
||||
]
|
||||
if message_items:
|
||||
last = message_items[-1]
|
||||
parts = last.get("content")
|
||||
if not isinstance(parts, list):
|
||||
parts = []
|
||||
texts = [p for p in parts if isinstance(p, dict) and p.get("type") == "output_text"]
|
||||
if texts:
|
||||
texts[-1]["text"] = (texts[-1].get("text") or "").rstrip() + "\n\n" + _EMPTY_NOTE
|
||||
else:
|
||||
parts.append({"type": "output_text", "text": _EMPTY_NOTE})
|
||||
last["content"] = parts
|
||||
else:
|
||||
# An output list of nothing but reasoning and tool traffic renders
|
||||
# as an empty message, and the note in `content` would never be
|
||||
# reached. Give it the one item the renderer reads.
|
||||
output_items.append(
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": _EMPTY_NOTE}],
|
||||
}
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- hooks
|
||||
|
||||
async def inlet(self, body: dict, __event_emitter__=None, __metadata__=None) -> dict:
|
||||
"""Mark the start of prompt processing. `body` is returned untouched."""
|
||||
if not self.valves.enabled:
|
||||
return body
|
||||
try:
|
||||
message_id = self._message_id(__metadata__)
|
||||
continued = False
|
||||
if message_id:
|
||||
state = _STATE.get(message_id)
|
||||
# inlet runs a SECOND time for the same assistant message when
|
||||
# the turn contains an interactive tool call - the native
|
||||
# ask_user widget re-enters the pipeline. Wiping the state
|
||||
# there zeroed the tool counter mid-turn, and a turn with
|
||||
# three real calls reported "Done · 2 tool calls". Keep what
|
||||
# has already been counted; reset only the per-round fields.
|
||||
if state and (state["tools"] or state["phase"]):
|
||||
continued = True
|
||||
state["round"] += 1
|
||||
state["phase"] = None
|
||||
state["started"] = time.monotonic()
|
||||
state["prompt_reported"] = False
|
||||
else:
|
||||
_STATE[message_id] = _new_state()
|
||||
_prune()
|
||||
if self.valves.show_prompt_processing:
|
||||
await self._emit(
|
||||
__event_emitter__,
|
||||
"Processing prompt (continued)" if continued else "Processing prompt",
|
||||
done=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Never modified. This filter must not touch the prompt.
|
||||
return body
|
||||
|
||||
async def stream(self, event: dict, __event_emitter__=None, __metadata__=None) -> dict:
|
||||
"""Announce phase changes. MUST return `event` on every path."""
|
||||
if not self.valves.enabled:
|
||||
return event
|
||||
try:
|
||||
if not isinstance(event, dict):
|
||||
return event
|
||||
|
||||
state = self._state(self._message_id(__metadata__))
|
||||
if state is None:
|
||||
return event
|
||||
|
||||
# Read before the `choices` checks below: the usage chunk carries
|
||||
# usage at the top level and an empty delta, and some providers
|
||||
# send it with no choices at all.
|
||||
usage = event.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
self._record_usage(state, usage)
|
||||
|
||||
choices = event.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
return event
|
||||
first = choices[0]
|
||||
if not isinstance(first, dict):
|
||||
return event
|
||||
delta = first.get("delta")
|
||||
if not isinstance(delta, dict):
|
||||
return event
|
||||
|
||||
tool_calls = delta.get("tool_calls")
|
||||
if tool_calls:
|
||||
for call in tool_calls if isinstance(tool_calls, list) else []:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
function = call.get("function")
|
||||
name = function.get("name") if isinstance(function, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
# A tool id is the reliable key; index plus round is the
|
||||
# fallback, so the same tool called twice still counts twice.
|
||||
key = call.get("id") or f"{state['round']}:{call.get('index', 0)}"
|
||||
if key in state["announced"]:
|
||||
continue
|
||||
state["announced"].add(key)
|
||||
# Counting, de-duplication and the phase change all sit
|
||||
# OUTSIDE the display valve: they used to be inside it, so
|
||||
# turning the announcements off silently zeroed the final
|
||||
# tool count as well, and stalled the round counter that
|
||||
# the de-duplication key depends on. Only the announcement
|
||||
# itself is optional.
|
||||
state["tools"] += 1
|
||||
# The reasoning that led to this call belongs before it.
|
||||
await self._flush_thought(state, __event_emitter__)
|
||||
state["phase"] = "tool"
|
||||
if self.valves.show_tool_calls:
|
||||
await self._emit(
|
||||
__event_emitter__,
|
||||
f"Calling {name}{self._suffix(state)}",
|
||||
done=False,
|
||||
)
|
||||
return event
|
||||
|
||||
reasoning = delta.get("reasoning_content")
|
||||
if reasoning:
|
||||
if isinstance(reasoning, str) and self.valves.thought_excerpt_chars > 0:
|
||||
# Only keep what an excerpt could possibly need, so a long
|
||||
# think cannot grow this without bound.
|
||||
if sum(len(p) for p in state["reasoning"]) < 4096:
|
||||
state["reasoning"].append(reasoning)
|
||||
if self.valves.show_thinking and state["phase"] != "thinking":
|
||||
if state["phase"] == "tool":
|
||||
state["round"] += 1
|
||||
state["phase"] = "thinking"
|
||||
await self._emit(
|
||||
__event_emitter__, f"Thinking{self._suffix(state)}", done=False
|
||||
)
|
||||
elif delta.get("content"):
|
||||
if state["phase"] != "writing":
|
||||
if state["phase"] == "tool":
|
||||
state["round"] += 1
|
||||
await self._flush_thought(state, __event_emitter__)
|
||||
state["phase"] = "writing"
|
||||
await self._emit(
|
||||
__event_emitter__,
|
||||
f"Writing response{self._suffix(state)}",
|
||||
done=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return event
|
||||
|
||||
async def outlet(self, body: dict, __event_emitter__=None, __metadata__=None) -> dict:
|
||||
"""Settle the status line and drop the per-message state."""
|
||||
if not self.valves.enabled:
|
||||
return body
|
||||
try:
|
||||
message_id = self._message_id(__metadata__)
|
||||
state = _STATE.pop(message_id, None) if message_id else None
|
||||
|
||||
# A response that ended while still reasoning still has something
|
||||
# worth showing.
|
||||
if state and not self.valves.clear_when_done:
|
||||
await self._flush_thought(state, __event_emitter__)
|
||||
|
||||
messages = body.get("messages")
|
||||
message = None
|
||||
if isinstance(messages, list) and messages and isinstance(messages[-1], dict):
|
||||
message = messages[-1]
|
||||
|
||||
prefill_s, prompt_tokens, gen_tokens = self._final_usage(state, message)
|
||||
|
||||
# Open WebUI builds outlet's `content` as content OR the rendered
|
||||
# text of `output`, so blank here means the turn really produced
|
||||
# no answer - not that the answer lives somewhere else.
|
||||
empty = (
|
||||
message is not None
|
||||
and message.get("role", "assistant") == "assistant"
|
||||
and not (message.get("content") or "").strip()
|
||||
)
|
||||
if empty:
|
||||
self._mark_empty(message)
|
||||
|
||||
if self.valves.clear_when_done and not empty:
|
||||
# StatusHistory hides the whole block when the LATEST status
|
||||
# is hidden, so this clears the line entirely.
|
||||
await self._emit(__event_emitter__, "", done=True, hidden=True)
|
||||
else:
|
||||
if empty:
|
||||
# Never hidden, even with clear_when_done: a turn that
|
||||
# produced nothing is the one turn whose status matters.
|
||||
# The token count is dropped rather than printed as 0 when
|
||||
# no usage arrived - "(0 tokens)" would be a measurement
|
||||
# nobody took.
|
||||
spent = f" ({gen_tokens:,} tokens)" if gen_tokens else ""
|
||||
summary = (
|
||||
"Done · no answer produced — the model stopped inside its "
|
||||
f"reasoning{spent}. Press Regenerate."
|
||||
)
|
||||
else:
|
||||
tools = state["tools"] if state else 0
|
||||
if tools == 1:
|
||||
summary = "Done · 1 tool call"
|
||||
elif tools > 1:
|
||||
summary = f"Done · {tools} tool calls"
|
||||
else:
|
||||
summary = "Done"
|
||||
summary += self._usage_clause(prefill_s, prompt_tokens, gen_tokens)
|
||||
await self._emit(__event_emitter__, summary, done=True)
|
||||
except Exception:
|
||||
pass
|
||||
return body
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
title: Quantitative Question Nudge
|
||||
author: owuitools
|
||||
version: 0.2.0
|
||||
description: Raises tool-use salience on measurement-bearing questions that are phrased casually.
|
||||
"""
|
||||
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# The system prompt already says that reasoning tasks need tools and that
|
||||
# ruling a cause out is itself a claim. It does not fire on questions that
|
||||
# are phrased conversationally. Measured case:
|
||||
#
|
||||
# "im logging vibration off a motor at 500Hz, motor runs 3600 rpm but
|
||||
# the biggest fft peak is at 40Hz. is my sensor broken?"
|
||||
#
|
||||
# Zero tool calls, and the answer stated "aliasing cannot move a 60 Hz peak
|
||||
# to 40 Hz - so 40 Hz is a REAL vibration". True of the fundamental and
|
||||
# wrong as an elimination: the 9th harmonic at 540 Hz folds to exactly
|
||||
# 40.0 Hz at that sample rate. That sends someone hunting a phantom
|
||||
# resonance instead of fitting an anti-alias filter. Adding a paragraph to
|
||||
# the system prompt did not change it - which is this suite's most repeated
|
||||
# finding: static prompt text loses to the shape of the conversation.
|
||||
#
|
||||
# An inlet note is a different lever. It arrives as the most recent thing
|
||||
# in the conversation rather than as line 40 of a long preamble, so it is
|
||||
# read as part of the current task. It is invisible to the user: inlet
|
||||
# rewrites what goes to the model, not what is displayed.
|
||||
#
|
||||
# Deliberately narrow. It fires only when a message carries an actual
|
||||
# measurement - a number with a unit - because that is what distinguishes
|
||||
# "is my sensor broken" from "is Postgres a good choice". A nudge on every
|
||||
# message would be one more paragraph the model learns to skim.
|
||||
#
|
||||
# WHY IT NAMES THE TOOLS
|
||||
#
|
||||
# The first version said "check it with a tool" and left the choice open.
|
||||
# On a compound-interest question the model duly reached for a tool - and
|
||||
# reached for web_search, to look up a formula it already knew, then did
|
||||
# the actual arithmetic in its head and got it wrong. Tool-seeking without
|
||||
# tool-selection. The note now says which tool does what, and says out loud
|
||||
# that searching for a formula you know is not the point.
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# A number bound to a unit. Bare numbers are excluded on purpose: "give me
|
||||
# 3 options" is not a measurement. Currency and durations are in because
|
||||
# "$4,000 at 5% for 10 years" is as quantitative as anything in volts, and
|
||||
# the arithmetic is exactly where in-head answers go wrong.
|
||||
_MEASUREMENT = re.compile(
|
||||
r"[$£€]\s?\d"
|
||||
r"|\b\d+(?:\.\d+)?\s?"
|
||||
r"(?:hz|khz|mhz|ghz|rpm|v|kv|mv|a|ma|w|kw|mw|va|kva|ohm|ohms|"
|
||||
r"db|dbm|s|ms|us|ns|m|mm|cm|km|kg|g|n|nm|pa|kpa|bar|psi|"
|
||||
r"c|f|k|%|gb|mb|kb|tb|bps|kbps|mbps|gbps|awg|bit|bits|"
|
||||
r"years?|yrs?|months?|weeks?|days?)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_NOTE = (
|
||||
"Reminder for this question: it contains measurements, so it is a technical question "
|
||||
"however casually it is worded. Before asserting any number, mechanism or standard, check "
|
||||
"it with a tool: the calculators (ohms_law_power, component_calc, wiring_calc, pcb_calc, "
|
||||
"subnet_calc) for what they cover, matlab_run for any other arithmetic - compounding, unit "
|
||||
"conversions, statistics, counting - and web_search then fetch_page for facts and "
|
||||
"standards, not for formulas you already know. In particular, if you are about to rule a "
|
||||
"cause OUT, enumerate the variants of that cause and test the ones you have not considered "
|
||||
"- a correct elimination of the obvious version and a missed elimination of a subtler "
|
||||
"version look identical in the answer, and send the user to fix the wrong thing."
|
||||
)
|
||||
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Turn the nudge off without deleting the filter.",
|
||||
)
|
||||
min_chars: int = Field(
|
||||
default=40,
|
||||
description="Skip very short messages. 'what is 3 dB' needs no reminder.",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.valves = self.Valves()
|
||||
|
||||
async def inlet(self, body: dict, __user__=None) -> dict:
|
||||
if not self.valves.enabled:
|
||||
return body
|
||||
try:
|
||||
messages = body.get("messages") or []
|
||||
if not messages:
|
||||
return body
|
||||
|
||||
last_user = None
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
last_user = message
|
||||
break
|
||||
if last_user is None:
|
||||
return body
|
||||
|
||||
text = last_user.get("content")
|
||||
if not isinstance(text, str) or len(text) < self.valves.min_chars:
|
||||
return body
|
||||
if not _MEASUREMENT.search(text):
|
||||
return body
|
||||
|
||||
# Appended AFTER the user's turn, not before it. Inserted
|
||||
# before, this was read as more preamble and changed nothing;
|
||||
# the last message in the list is the one that gets weight.
|
||||
messages.append({"role": "system", "content": _NOTE})
|
||||
body["messages"] = messages
|
||||
except Exception:
|
||||
return body
|
||||
return body
|
||||
@@ -0,0 +1,788 @@
|
||||
"""
|
||||
title: VM Agent Progress
|
||||
author: owuitools
|
||||
version: 0.1.0
|
||||
description: While vm_agent is running, shows what the sub-agent is doing and, when it looks at the machine's screen, what it sees.
|
||||
"""
|
||||
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# A vm_agent call is one tool call that takes minutes to hours: a slow
|
||||
# model with thinking on, driving a real VM. Open WebUI shows "Calling
|
||||
# vm_agent" and then nothing at all until it returns. That is the single
|
||||
# longest silence this deployment can produce, and it is silent precisely
|
||||
# when the most is happening.
|
||||
#
|
||||
# WHY IT IS A FILTER AND NOT THE AGENT POSTING DIRECTLY
|
||||
#
|
||||
# The first design had the agent POST to
|
||||
# /api/v1/chats/{id}/messages/{id}/event. That endpoint refuses a key
|
||||
# whose user neither owns the chat nor is an admin - and anyone on this
|
||||
# instance may invoke vm_agent, so it had to be an ADMIN key. An admin
|
||||
# key is the ability to read every conversation on the instance and
|
||||
# create users, and it would have had to live somewhere outside Open
|
||||
# WebUI to be usable.
|
||||
#
|
||||
# Here there is no such key. This code runs INSIDE Open WebUI, in the
|
||||
# same process as the chat pipeline, and __event_emitter__ is already
|
||||
# bound to the requesting user's own session. The only credential it
|
||||
# holds is the owuitools tool-server bearer token - which grants exactly
|
||||
# the tools the model can already call, and nothing else.
|
||||
#
|
||||
# The cost is that progress is polled rather than pushed: a 2-second poll
|
||||
# for the length of a session. That is the whole of the trade.
|
||||
#
|
||||
# WHY NONE OF THIS REACHES THE MODEL
|
||||
#
|
||||
# Open WebUI stores a message's status history and its `embeds` as
|
||||
# SIBLING fields of the message, and rebuilds the payload sent to the
|
||||
# model from an allowlist - role, thinking, content, tool_calls,
|
||||
# tool_call_id, images (utils/payload.py). Neither `statusHistory` nor
|
||||
# `embeds` is on that list. So a screenshot shown here is seen by the
|
||||
# person watching and never by the model that called the tool, which is
|
||||
# the entire reason for doing it this way rather than returning images
|
||||
# from the tool itself.
|
||||
#
|
||||
# TWO SHARP EDGES IN THE stream() HOOK, both learned the hard way in
|
||||
# live_progress_status.py and both still true here:
|
||||
# * a falsy return value DROPS the chunk. Every path returns `event`.
|
||||
# * an exception is re-raised by Open WebUI and breaks the response
|
||||
# mid-flight. The whole body is wrapped.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Must match vmagent/app/events.py. Twenty lines is enough to see a
|
||||
# command and what it said, and short enough that the view does not become
|
||||
# the conversation.
|
||||
TERMINAL_LINES = 20
|
||||
|
||||
# How often the whole view is re-sent even when it has not changed, so a
|
||||
# viewer who reloads or joins late resyncs without waiting for the next
|
||||
# change. See the note in _watch.
|
||||
RESEND_SECONDS = 10.0
|
||||
# How long a session may say nothing at all before the status line starts
|
||||
# counting instead of repeating itself. See the note in _watch.
|
||||
QUIET_SECONDS = 25.0
|
||||
|
||||
|
||||
|
||||
# The frame tells the page how tall it is. See _embed's note for why the
|
||||
# other route - the page measuring the frame - cannot work here.
|
||||
#
|
||||
# It measures the CONTENT, not the document. `html{overflow:hidden}` makes
|
||||
# `documentElement.scrollHeight` report the frame's current height whenever
|
||||
# the content is shorter than it, so a box measured that way can grow but
|
||||
# can never shrink again: measured, a seven-line list in a 150px frame
|
||||
# reported 150 rather than its real 135. The wrapper's own bounding box has
|
||||
# no such floor. A zero is never sent - a frame in a background tab has no
|
||||
# layout and measures 0, and the observer reports the real height as soon
|
||||
# as the tab is looked at.
|
||||
# The one script every box carries: it measures itself, remembers whether
|
||||
# it has been clicked shut, and reveals the live line at the rate the
|
||||
# tokens arrived.
|
||||
#
|
||||
# WHY STATE LIVES IN window.name, WHICH LOOKS LIKE A HACK AND IS THE ONLY
|
||||
# THING THAT WORKS HERE.
|
||||
#
|
||||
# The view is re-sent every couple of seconds, and each send replaces the
|
||||
# frame's `srcdoc` - a new Document every time. The frame is sandboxed
|
||||
# without `allow-same-origin`, so it has an opaque origin: localStorage
|
||||
# and cookies throw, IndexedDB is useless, and there is no listener in
|
||||
# Open WebUI to postMessage to. `window.name` belongs to the browsing
|
||||
# CONTEXT rather than the document, and the context survives the swap.
|
||||
# Measured in this exact sandbox: a frame reloaded three times read back
|
||||
# the name its two predecessors had written. Without it a box would
|
||||
# spring open again a second after being closed.
|
||||
#
|
||||
# The state is keyed by which box this is, because the boxes change
|
||||
# places: the screen box appears mid-session and pushes the feed down one
|
||||
# slot, and the feed would otherwise inherit the screen's state.
|
||||
_VIEW_SCRIPT = """
|
||||
<script>
|
||||
(function(){
|
||||
var b=document.getElementById('b');if(!b){return;}
|
||||
var cfg={};try{cfg=JSON.parse(document.getElementById('cfg').textContent);}catch(e){}
|
||||
var kind=cfg.k||'';
|
||||
var st={};try{st=JSON.parse(window.name||'{}')||{};}catch(e){st={};}
|
||||
if(st.k!==kind){st={k:kind};}
|
||||
function save(){try{window.name=JSON.stringify(st);}catch(e){}}
|
||||
function el(id){return document.getElementById(id);}
|
||||
function post(){var h=Math.ceil(b.getBoundingClientRect().height);
|
||||
if(h>0){parent.postMessage({type:'iframe:height',height:h},'*');}}
|
||||
|
||||
/* Clicking a box halves it; clicking again puts it back. */
|
||||
function apply(){
|
||||
var on=!!st.c;
|
||||
if(kind==='tasks'){
|
||||
var f=el('full'),c=el('cur');
|
||||
if(f){f.style.display=on?'none':'';}
|
||||
if(c){c.style.display=on?'':'none';}
|
||||
}else if(kind==='screen'){
|
||||
var w=el('wrap'),i=el('shot');
|
||||
if(w){w.style.background=on?'#000':'';}
|
||||
if(i){i.style.width=on?'50%':'100%';i.style.margin=on?'0 auto':'';}
|
||||
}else if(kind==='feed'){
|
||||
var r=el('rows');
|
||||
if(r){
|
||||
if(on){var first=r.firstElementChild;
|
||||
var rh=first?first.getBoundingClientRect().height:17;
|
||||
r.style.maxHeight=Math.round(rh*Math.ceil((cfg.n||20)/2))+'px';}
|
||||
else{r.style.maxHeight='';}}
|
||||
}}
|
||||
b.style.cursor='pointer';
|
||||
b.addEventListener('click',function(){st.c=st.c?0:1;save();apply();post();});
|
||||
apply();
|
||||
|
||||
/* THE LIVE LINE, REVEALED AT THE RATE IT ARRIVED.
|
||||
The server sends the tail of what the model is saying, at most once a
|
||||
second, so this line used to jump a clause at a time - motion that
|
||||
said nothing about how fast the tokens were actually coming. It is now
|
||||
appended a few characters per frame, and the speed it moves IS the
|
||||
token rate. The text so far rides in window.name, so a replaced
|
||||
document carries on the same line instead of retyping it. */
|
||||
if(kind==='tasks'){
|
||||
var span=el('t');
|
||||
var target=cfg.s||'';
|
||||
var shown=(typeof st.d==='string')?st.d:'';
|
||||
var pending='';
|
||||
if(target){
|
||||
if(shown&&shown.indexOf(target)>=0){pending='';}
|
||||
else{
|
||||
/* The server sends a sliding window of the tail, so what is new is
|
||||
whatever follows the longest overlap between the two. */
|
||||
var o=0,max=Math.min(shown.length,target.length);
|
||||
for(var k=max;k>0;k--){
|
||||
if(shown.slice(shown.length-k)===target.slice(0,k)){o=k;break;}}
|
||||
if(o===0){shown='';}
|
||||
pending=target.slice(o);}}
|
||||
var CAP=400;
|
||||
function paint(){if(shown.length>CAP){shown=shown.slice(-CAP);}
|
||||
if(span){span.textContent=shown;}}
|
||||
paint();
|
||||
if(pending){
|
||||
var TICK=40,DUR=1500;
|
||||
var per=Math.max(1,Math.ceil(pending.length/(DUR/TICK)));
|
||||
var timer=setInterval(function(){
|
||||
if(!pending.length){clearInterval(timer);return;}
|
||||
shown+=pending.slice(0,per);pending=pending.slice(per);
|
||||
paint();st.d=shown;save();},TICK);}
|
||||
st.d=shown;save();}
|
||||
|
||||
post();addEventListener('load',post);
|
||||
if(window.ResizeObserver){new ResizeObserver(post).observe(b);}
|
||||
setTimeout(post,60);setTimeout(post,400);
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
class Filter:
|
||||
class Valves(BaseModel):
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Turn the whole filter off without deleting it.",
|
||||
)
|
||||
owuitools_url: str = Field(
|
||||
default="http://owuitools-gw:8000",
|
||||
description="The owuitools gateway, as Open WebUI reaches it. This is the same "
|
||||
"address as the tool server connection and is not published to the host.",
|
||||
)
|
||||
api_key: str = Field(
|
||||
default="",
|
||||
description="The owuitools bearer token - the SAME value as the tool server "
|
||||
"connection's Auth field, not an Open WebUI API key. It grants the tools the "
|
||||
"model can already call and nothing more. Without it this filter does nothing.",
|
||||
)
|
||||
poll_seconds: float = Field(
|
||||
default=2.0,
|
||||
description="How often to ask for progress while a session is running.",
|
||||
)
|
||||
show_terminal: bool = Field(
|
||||
default=True,
|
||||
description="Show the last 20 lines of what the agent ran.",
|
||||
)
|
||||
show_screenshots: bool = Field(
|
||||
default=True,
|
||||
description="Show the machine's screen when the agent takes a screenshot. Each frame is stored "
|
||||
"on the message, so turning this off makes the chat record smaller.",
|
||||
)
|
||||
max_minutes: float = Field(
|
||||
default=125.0,
|
||||
description="Give up watching after this long. A backstop so a watcher can never "
|
||||
"outlive the session it is watching, whatever goes wrong.",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.valves = self.Valves()
|
||||
# One watcher per message id. A chat can only have one assistant
|
||||
# message in flight, but a user with two tabs open has two chats.
|
||||
self._watchers: dict[str, asyncio.Task] = {}
|
||||
|
||||
# -- Open WebUI hooks -----------------------------------------------
|
||||
async def inlet(self, body: dict, __event_emitter__=None, __event_call__=None,
|
||||
__metadata__=None) -> dict:
|
||||
# A previous turn's watcher has no business surviving into this
|
||||
# one; inlet is the only hook guaranteed to run at the start.
|
||||
try:
|
||||
self._stop(self._message_id(__metadata__))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return body
|
||||
|
||||
async def stream(self, event: dict, __event_emitter__=None, __event_call__=None,
|
||||
__metadata__=None) -> dict:
|
||||
try:
|
||||
if self.valves.enabled and self._is_vm_agent_call(event):
|
||||
self._start(__event_emitter__, __event_call__, __metadata__)
|
||||
except Exception: # noqa: BLE001
|
||||
# Never break the response. See the header.
|
||||
pass
|
||||
return event
|
||||
|
||||
async def outlet(self, body: dict, __event_emitter__=None, __event_call__=None,
|
||||
__metadata__=None) -> dict:
|
||||
try:
|
||||
self._stop(self._message_id(__metadata__))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return body
|
||||
|
||||
# -- detection -------------------------------------------------------
|
||||
@staticmethod
|
||||
def _is_vm_agent_call(event: dict) -> bool:
|
||||
"""Has the model just started writing a vm_agent call?
|
||||
|
||||
Tool calls arrive token by token, so the name may appear in a
|
||||
delta with nothing else in it. Starting the watcher more than
|
||||
once is harmless - _start is idempotent per message.
|
||||
"""
|
||||
for choice in (event or {}).get("choices") or []:
|
||||
delta = choice.get("delta") or {}
|
||||
for call in delta.get("tool_calls") or []:
|
||||
if (call.get("function") or {}).get("name") == "vm_agent":
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _message_id(metadata) -> str:
|
||||
return str((metadata or {}).get("message_id") or "")
|
||||
|
||||
# -- the watcher ------------------------------------------------------
|
||||
def _start(self, emitter, caller, metadata) -> None:
|
||||
message_id = self._message_id(metadata)
|
||||
chat_id = str((metadata or {}).get("chat_id") or "")
|
||||
if not emitter or not message_id or not chat_id:
|
||||
return
|
||||
if not self.valves.api_key:
|
||||
return
|
||||
if message_id in self._watchers and not self._watchers[message_id].done():
|
||||
return
|
||||
self._watchers[message_id] = asyncio.create_task(
|
||||
self._watch(emitter, caller, chat_id))
|
||||
|
||||
def _stop(self, message_id: str) -> None:
|
||||
task = self._watchers.pop(message_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
|
||||
async def _watch(self, emitter, caller, chat_id: str) -> None:
|
||||
url = f"{self.valves.owuitools_url.rstrip('/')}/vm/progress/{chat_id}"
|
||||
headers = {"Authorization": f"Bearer {self.valves.api_key}"}
|
||||
deadline = asyncio.get_running_loop().time() + self.valves.max_minutes * 60
|
||||
began = asyncio.get_running_loop().time()
|
||||
# Wall clock, because that is what the progress file is stamped
|
||||
# with; the loop clock above is a monotonic reading and the two
|
||||
# are not comparable.
|
||||
began_at = time.time()
|
||||
last_status = ""
|
||||
last_view = None
|
||||
shown_anything = False
|
||||
# WHY THE WHOLE VIEW IS RE-SENT EVEN WHEN NOTHING HAS MOVED.
|
||||
#
|
||||
# These updates are pushed to whoever is connected. Someone who
|
||||
# reloads the page, or opens the chat on another device, joins
|
||||
# partway through and gets only what is NEXT - and a session can
|
||||
# sit on one step for minutes ("Waiting for the machine to finish
|
||||
# booting" held for four polls in a row, measured), so a viewer who
|
||||
# arrives just after a change sees an empty message for as long as
|
||||
# that step lasts and concludes nothing is happening.
|
||||
#
|
||||
# So the current view is re-sent on a slow heartbeat regardless of
|
||||
# whether it changed. `replace: true` makes that idempotent - it
|
||||
# overwrites the one live view rather than appending a frame - so
|
||||
# the cost is one small message every RESEND_SECONDS and the
|
||||
# benefit is that reattaching fixes itself.
|
||||
next_resend = 0.0
|
||||
ended_cleanly = False
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
await self._status(emitter, "Starting a machine…")
|
||||
asked: set[str] = set()
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
await asyncio.sleep(self.valves.poll_seconds)
|
||||
|
||||
# Before a machine is started, the tool waits for the
|
||||
# person to see the job. Putting that question is this
|
||||
# watcher's job: it is already here, it already has the
|
||||
# chat, and doing it here means the model plays no part
|
||||
# in it and cannot skip it.
|
||||
if caller is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._maybe_ask(session, caller, chat_id, headers, asked)
|
||||
try:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status != 200:
|
||||
continue
|
||||
data = json.loads(await response.text())
|
||||
except (aiohttp.ClientError, ValueError, asyncio.TimeoutError):
|
||||
# A poll that fails is a poll; the session is not
|
||||
# affected by it and neither is the next one.
|
||||
continue
|
||||
|
||||
# ANYTHING OLDER THAN THIS WATCHER BELONGS TO A PREVIOUS RUN.
|
||||
#
|
||||
# The progress file is named after the CHAT, so a second
|
||||
# vm_agent call in the same conversation finds the first
|
||||
# one's final state sitting there - including `done`.
|
||||
# Measured: a watcher for a 45-minute run read that on
|
||||
# its first poll, announced the machine had been
|
||||
# destroyed and exited four seconds in, while the run
|
||||
# carried on unobserved to sequence 1035.
|
||||
#
|
||||
# The api deletes the file before queueing the job, which
|
||||
# closes the race properly; this is the second guard,
|
||||
# because a race with one guard is a race. `updated` is
|
||||
# written on every flush, so a file from the last run is
|
||||
# simply older than the watcher reading it.
|
||||
if float(data.get("updated") or 0) < began_at:
|
||||
data = {}
|
||||
if not data:
|
||||
# NOTHING YET, AND SAYING SO BEATS SAYING NOTHING.
|
||||
#
|
||||
# "Starting a machine" is the first thing this
|
||||
# emits, before there is any progress to read. If
|
||||
# the file never appears - the job queued behind
|
||||
# another session, the runner restarting, a boot
|
||||
# that is simply slow - that sentence is the last
|
||||
# word the reader gets, and a frozen line is
|
||||
# indistinguishable from a broken one. Reported as
|
||||
# exactly that: a toast stuck on "Starting a
|
||||
# machine" with no boxes.
|
||||
#
|
||||
# So it counts, and says how long. A number that
|
||||
# moves is the difference between waiting and
|
||||
# wondering.
|
||||
waited = int(asyncio.get_running_loop().time() - began)
|
||||
if waited >= QUIET_SECONDS:
|
||||
shown_anything = True
|
||||
await self._status(
|
||||
emitter,
|
||||
f"Starting a machine - {waited}s so far, nothing back "
|
||||
"from it yet")
|
||||
continue
|
||||
|
||||
now = asyncio.get_running_loop().time()
|
||||
resend = now >= next_resend
|
||||
if resend:
|
||||
next_resend = now + RESEND_SECONDS
|
||||
|
||||
status = str(data.get("status") or "")
|
||||
if status and (status != last_status or resend):
|
||||
last_status = status
|
||||
shown_anything = True
|
||||
await self._status(emitter, status)
|
||||
|
||||
# One embed holds the whole view - the task list and,
|
||||
# under it, whichever of the two the session has. It is
|
||||
# rebuilt when any part of it moves, and `replace: true`
|
||||
# means the message carries one of these rather than a
|
||||
# reel of every frame ever taken.
|
||||
view = (str(data.get("tasks") or ""),
|
||||
str(data.get("speech") or ""),
|
||||
int(data.get("shot_seq") or 0),
|
||||
int(data.get("terminal_seq") or 0))
|
||||
if view != last_view or resend:
|
||||
last_view = view
|
||||
await self._embed(emitter, data, self.valves)
|
||||
|
||||
if data.get("done"):
|
||||
ended_cleanly = True
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
finally:
|
||||
# Only say the machine is gone if the session actually said it
|
||||
# was done. This block also runs when the watcher is cancelled
|
||||
# or hits its own deadline - and in those cases the session is
|
||||
# very much still running, so the old wording stated something
|
||||
# false and wiped the view while there was still work to show.
|
||||
with contextlib.suppress(Exception):
|
||||
await self._embed(emitter, {}, self.valves)
|
||||
if shown_anything:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._status(
|
||||
emitter,
|
||||
"The machine has been destroyed." if ended_cleanly
|
||||
else "No longer watching this session.",
|
||||
done=True)
|
||||
|
||||
# WHAT THE PERSON IS SHOWN, AND WHY IT IS NOT THE JOB ITSELF.
|
||||
#
|
||||
# The job the model writes is an instruction sheet for another machine:
|
||||
# numbered steps, exact commands, the wording that stops an agent
|
||||
# improvising. Measured, a real one ran to 2,600 characters. Nobody
|
||||
# reads that to decide whether to press OK - and worse, Open WebUI does
|
||||
# not scroll the dialog, so a job that tall pushed OK and Cancel 150px
|
||||
# below the bottom of an 800px window and the question could not be
|
||||
# answered at all.
|
||||
#
|
||||
# So the dialog gets a short summary of what the job will DO.
|
||||
#
|
||||
# IT IS BUILT HERE, FROM THE JOB, rather than written by the model.
|
||||
# This is the one screen where a summary that drifts from the text is
|
||||
# a real problem: the person approves the summary and the machine is
|
||||
# sent the job. A summary pulled out of the job's own headings cannot
|
||||
# describe work that is not in it, and asking the model for one would
|
||||
# be both a second thing to trust and more work for it to do.
|
||||
SUMMARY_STEPS = 5 # bullets before the rest become a count
|
||||
SUMMARY_HEAD = 150 # characters of the opening line
|
||||
SUMMARY_STEP = 64 # characters of any one bullet - ONE LINE each
|
||||
|
||||
# A step is a numbered line or a STEP heading. A bullet is only a step
|
||||
# when there are no headings - measured on a real job, the bullets were
|
||||
# the data the steps operated on ("- /tmp/logtest/recent.log -> mtime =
|
||||
# now"), and treating them as steps pushed the actual steps out of the
|
||||
# summary entirely.
|
||||
_STEP_NUM = re.compile(
|
||||
r"^\s*(?:step\s*\d+|\d+\s*[.)])\s*[:.\u2013\u2014-]*\s*(.+)$", re.IGNORECASE)
|
||||
_BULLET = re.compile(r"^\s*[-*\u2022]\s*(.+)$")
|
||||
# Sentences end at . ! ? and nothing else. Splitting on a colon as well
|
||||
# turned "Run: ps -p 1 -o comm=" into the bullet "Run:", which says
|
||||
# nothing - the useful half is always on the other side of the colon.
|
||||
_SENTENCE = re.compile(r"(?<=[.!?])\s")
|
||||
# "Create a clean test directory, e.g. /tmp/logtest" is one sentence.
|
||||
# Splitting on every full stop cut that bullet off at "e.g".
|
||||
_NOT_AN_END = ("e.g", "i.e", "etc", "vs", "no", "fig", "approx", "cf")
|
||||
# The word before the stop carries whatever punctuation was around it -
|
||||
# "(i.e." and "e.g.," both have to reduce to the abbreviation itself.
|
||||
_LAST_WORD = re.compile(r"[^a-z.]", re.IGNORECASE)
|
||||
_LABEL = re.compile(r"^(goal|task|objective|summary)\s*[:-]\s*", re.IGNORECASE)
|
||||
|
||||
@classmethod
|
||||
def _headline(cls, text: str, cap: int) -> str:
|
||||
"""The first sentence of something, cut to length without cutting a word."""
|
||||
text = " ".join(text.split())
|
||||
parts = cls._SENTENCE.split(text)
|
||||
first = parts[0]
|
||||
# Keep absorbing while the "sentence" ended on an abbreviation.
|
||||
while (len(parts) > 1
|
||||
and cls._LAST_WORD.sub("", first.rsplit(" ", 1)[-1]).strip(".").lower()
|
||||
in cls._NOT_AN_END):
|
||||
parts = parts[1:]
|
||||
first = f"{first} {parts[0]}"
|
||||
first = first.strip()
|
||||
if len(first) <= cap:
|
||||
return first.rstrip(".")
|
||||
cut = first[:cap]
|
||||
space = cut.rfind(" ")
|
||||
return (cut[:space] if space > cap // 2 else cut).rstrip(" ,;:-") + "..."
|
||||
|
||||
@classmethod
|
||||
def _summarise(cls, task: str) -> str:
|
||||
"""What this job will do, in a few lines, taken from the job itself."""
|
||||
task = task.strip()
|
||||
if not task:
|
||||
return "(the job was empty)"
|
||||
lines = [line for line in task.splitlines() if line.strip()]
|
||||
|
||||
def heads(pattern) -> list:
|
||||
found = []
|
||||
for index, line in enumerate(lines):
|
||||
match = pattern.match(line)
|
||||
if not match:
|
||||
continue
|
||||
body = match.group(1)
|
||||
# A step that is only a label - "Report:", "Then:" - says
|
||||
# nothing by itself, so it takes what is under it.
|
||||
if body.rstrip().endswith(":") and len(body.split()) <= 3:
|
||||
following = lines[index + 1] if index + 1 < len(lines) else ""
|
||||
under = cls._BULLET.match(following)
|
||||
body = f"{body.rstrip()} {(under.group(1) if under else following).strip()}"
|
||||
body = body.strip()
|
||||
step = cls._headline(body, cls.SUMMARY_STEP)
|
||||
if step:
|
||||
found.append(step)
|
||||
return found
|
||||
|
||||
steps = heads(cls._STEP_NUM) or heads(cls._BULLET)
|
||||
|
||||
# An opening line that is itself a step is the first bullet, not the
|
||||
# headline; anything else - "Goal: ...", a sentence of context - is.
|
||||
head = ""
|
||||
if not (cls._STEP_NUM.match(lines[0]) or
|
||||
(not heads(cls._STEP_NUM) and cls._BULLET.match(lines[0]))):
|
||||
head = cls._headline(cls._LABEL.sub("", lines[0]), cls.SUMMARY_HEAD)
|
||||
|
||||
if not steps:
|
||||
# No headings to work from, so the summary is the first few
|
||||
# sentences of the prose - still shorter than the whole thing.
|
||||
rest = cls._SENTENCE.split(" ".join(task.split()))[1:]
|
||||
steps = [step for step in
|
||||
(cls._headline(part, cls.SUMMARY_STEP)
|
||||
for part in rest[:cls.SUMMARY_STEPS]) if step]
|
||||
if not steps:
|
||||
return head or cls._headline(task, cls.SUMMARY_HEAD)
|
||||
|
||||
# Open WebUI renders this dialog as markdown, and a plain newline there
|
||||
# is a SOFT break: consecutive lines are joined into one paragraph.
|
||||
# Measured, that ran two steps together on one wrapped line. A "- "
|
||||
# list item at column 0 is the one form the renderer keeps on its own
|
||||
# line - a leading indent turns it into a nested or literal block, and
|
||||
# a bullet character of my own is not markdown at all and gets joined.
|
||||
out = [head] if head else []
|
||||
shown = steps[:cls.SUMMARY_STEPS]
|
||||
out += [f"- {step}" for step in shown]
|
||||
left = len(steps) - len(shown)
|
||||
if left > 0:
|
||||
out.append(f"- and {left} more step{'' if left == 1 else 's'}")
|
||||
return "\n".join(out)
|
||||
|
||||
async def _maybe_ask(self, session, caller, chat_id: str, headers: dict,
|
||||
asked: set) -> None:
|
||||
"""Show the person the job, and send back what they decide.
|
||||
|
||||
Deliberately one dialog rather than three buttons. Open WebUI can
|
||||
show a box with a message and a text field, and that covers all
|
||||
three answers in one step without the person having to explain
|
||||
themselves twice:
|
||||
|
||||
leave it empty and press OK -> it runs as written
|
||||
type what to change -> it comes back rewritten
|
||||
press Cancel -> nothing is started
|
||||
|
||||
Typing the change here is what keeps the model out of it. The old
|
||||
shape would have been "the model asks what you want changed",
|
||||
which is another round trip and another chance to lose the sense
|
||||
of what was said.
|
||||
"""
|
||||
base = self.valves.owuitools_url.rstrip("/")
|
||||
async with session.get(f"{base}/vm/confirm/{chat_id}", headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
return
|
||||
pending = json.loads(await resp.text())
|
||||
request_id = str(pending.get("id") or "")
|
||||
if not request_id or request_id in asked:
|
||||
return
|
||||
asked.add(request_id)
|
||||
|
||||
machine = pending.get("machine") or "test"
|
||||
# "a Ubuntu machine" reads as a typo. The two names are fixed, so
|
||||
# the article is decided by the name rather than by a rule.
|
||||
article = "an" if machine[:1].upper() in "AEIOU" else "a"
|
||||
project = pending.get("project") or ""
|
||||
where = f"Kept in '{project}'." if project else "Nothing will be kept."
|
||||
# EVERY LINE HERE IS PAID FOR TWICE.
|
||||
#
|
||||
# Open WebUI does not scroll this dialog, so anything that does not
|
||||
# fit pushes OK and Cancel off the bottom and the question cannot
|
||||
# be answered at all. Measured in a 631px-tall window: a 21-line
|
||||
# message put the buttons 65px past the edge. That is not an
|
||||
# unusual window - it is a browser that is not maximised.
|
||||
#
|
||||
# So the framing is one line, the summary is five one-line bullets,
|
||||
# and the instructions are one line, because the text box under
|
||||
# them already says "Empty = run it. Or say what to change."
|
||||
message = (
|
||||
f"A throwaway {machine} machine, destroyed afterwards, no access to your "
|
||||
f"network. {where}\n\n"
|
||||
"WHAT IT WILL DO\n"
|
||||
f"{self._summarise(str(pending.get('task') or ''))}\n\n"
|
||||
"Empty + OK runs it \u00b7 type what to change \u00b7 Cancel drops it."
|
||||
)
|
||||
decision, text = "cancel", ""
|
||||
try:
|
||||
reply = await caller({
|
||||
"type": "input",
|
||||
"data": {"title": f"Start {article} {machine} machine?",
|
||||
"message": message,
|
||||
"placeholder": "Empty = run it. Or say what to change."},
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
# If the question could not be put, the job is what was asked
|
||||
# for. A confirmation that fails must not stop the work.
|
||||
reply = ""
|
||||
if reply is None or reply is False:
|
||||
decision = "cancel"
|
||||
else:
|
||||
text = str(reply).strip()
|
||||
decision = "change" if text else "run"
|
||||
with contextlib.suppress(Exception):
|
||||
await session.post(f"{base}/vm/confirm/{chat_id}", headers=headers,
|
||||
json={"id": request_id, "decision": decision, "text": text})
|
||||
|
||||
# -- emitting ----------------------------------------------------------
|
||||
@staticmethod
|
||||
async def _status(emitter, description: str, done: bool = False) -> None:
|
||||
await emitter({"type": "status",
|
||||
"data": {"description": f"VM · {description}" if not done else description,
|
||||
"done": done, "hidden": False}})
|
||||
|
||||
@staticmethod
|
||||
async def _embed(emitter, data: dict, valves) -> None:
|
||||
"""Replace the message's live view: the task list, then the machine.
|
||||
|
||||
TWO BOXES, NOT ONE, AND NEITHER OF THEM SCROLLS.
|
||||
|
||||
Open WebUI renders each entry of `embeds` in its OWN iframe, so
|
||||
two entries is two boxes - which is what was asked for.
|
||||
|
||||
HOW A BOX GETS THE RIGHT HEIGHT, which is not what it looks like.
|
||||
|
||||
Open WebUI tries twice to size one of these frames:
|
||||
|
||||
1. on load it reads `contentDocument.body.scrollHeight + 20`;
|
||||
2. it listens for `postMessage({type:"iframe:height", height})`
|
||||
from the frame and sets that height exactly.
|
||||
|
||||
The first one CANNOT WORK HERE and fails silently. The frame is
|
||||
sandboxed without `allow-same-origin`, so `contentDocument` throws
|
||||
a cross-origin error, and the read sits inside a bare `catch {}`.
|
||||
The height is then never set at all and the frame keeps its
|
||||
stylesheet default of 150px - which is the whole reason the box
|
||||
had a scrollbar. Measured: a seven-line task list rendering 168px
|
||||
tall inside a 150px frame.
|
||||
|
||||
So the frame measures itself and posts its own height, which is
|
||||
route 2 and needs no same-origin. `allowScripts` is hard-coded on
|
||||
for embeds, so the script runs. It reports on load, on every
|
||||
resize, and once more shortly after - the last of those is for a
|
||||
screenshot that has not finished decoding when the rest is ready.
|
||||
"""
|
||||
tasks = str(data.get("tasks") or "")
|
||||
speech = str(data.get("speech") or "")
|
||||
status = str(data.get("status") or "")
|
||||
shot = str(data.get("shot") or "")
|
||||
terminal = list(data.get("terminal") or [])
|
||||
|
||||
if not tasks and not speech and not shot and not terminal:
|
||||
await emitter({"type": "embeds", "data": {"replace": True, "embeds": []}})
|
||||
return
|
||||
|
||||
def esc(text: str) -> str:
|
||||
return (text.replace("&", "&").replace("<", "<").replace(">", ">"))
|
||||
|
||||
def frame(inner: str, background: str, kind: str, **config) -> str:
|
||||
# The config goes in as JSON rather than as generated JavaScript:
|
||||
# it carries the model's own words, and a quote or a backslash in
|
||||
# those would otherwise end the script. `</` is escaped because
|
||||
# the HTML parser ends this block at the first `</script>`,
|
||||
# wherever it appears - and `\/` is a legal JSON escape.
|
||||
payload = json.dumps({"k": kind, **config}).replace("</", "<\\/")
|
||||
return (
|
||||
"<style>html,body{margin:0;padding:0;overflow:hidden}"
|
||||
"*{box-sizing:border-box}</style>"
|
||||
f'<div id="b" style="font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,'
|
||||
f'monospace;color:#c9c9c9;background:{background};border-radius:6px;'
|
||||
f'padding:8px 10px;overflow:hidden">{inner}</div>'
|
||||
f'<script type="application/json" id="cfg">{payload}</script>'
|
||||
+ _VIEW_SCRIPT
|
||||
)
|
||||
|
||||
embeds = []
|
||||
|
||||
# BOX ONE: what the model is saying, then the list it is working to.
|
||||
#
|
||||
# The live line sits above the task list and carries no label. It
|
||||
# is the model's own voice mid-turn, and a turn on this model runs
|
||||
# tens of seconds - without it the box is motionless for most of a
|
||||
# session and "still thinking" looks the same as "wedged". One
|
||||
# line, and the script above reveals it a few characters at a time
|
||||
# so that its speed is the token rate rather than a poll artefact.
|
||||
#
|
||||
# It is right-anchored: the server sends the TAIL of the sentence,
|
||||
# so the end is the part that is moving and the end is what stays
|
||||
# against the edge while the rest slides off to the left.
|
||||
#
|
||||
# Clicked, the box becomes those two lines and nothing else: the
|
||||
# live line, and the step the session is on.
|
||||
if tasks or speech:
|
||||
rows = ('<div id="live" style="display:flex;justify-content:flex-end;'
|
||||
'overflow:hidden;min-height:17px;color:#8f8f8f">'
|
||||
f'<span id="t" style="white-space:pre">{esc(speech)}</span></div>')
|
||||
if tasks:
|
||||
rows += '<div id="full">'
|
||||
rows += '<div style="height:6px"></div>'
|
||||
rows += '<div style="color:#9aa0a6">Task List:</div>'
|
||||
rows += "".join(
|
||||
f'<div style="white-space:pre-wrap;word-break:break-word">{esc(line)}</div>'
|
||||
for line in tasks.splitlines()
|
||||
)
|
||||
rows += "</div>"
|
||||
rows += ('<div id="cur" style="display:none;white-space:pre;overflow:hidden;'
|
||||
f'text-overflow:ellipsis;color:#9aa0a6">{esc(status)}</div>')
|
||||
embeds.append(frame(rows, "#1b1b1b", "tasks", s=speech))
|
||||
|
||||
# BOX TWO, ONLY ONCE THERE IS SOMETHING TO SHOW.
|
||||
#
|
||||
# It used to appear for every desktop session, from the handover
|
||||
# onwards, which meant a picture of an idle desktop sat in the
|
||||
# chat before anything had happened - and most jobs never look at
|
||||
# the screen at all. The operator's words: pop the preview up when
|
||||
# the agent takes a screenshot. So this box exists only when a
|
||||
# screenshot does, and its arrival means something.
|
||||
#
|
||||
# Clicked, the picture goes to half size. It keeps its shape, so
|
||||
# half the height is half the width, and the black the box is
|
||||
# filled with shows either side of it.
|
||||
if valves.show_screenshots and shot:
|
||||
caption = esc(str(data.get("caption") or ""))[:200]
|
||||
embeds.append(frame(
|
||||
f'<div id="wrap"><img id="shot" src="{shot}" style="width:100%;'
|
||||
'height:auto;display:block;border-radius:4px" '
|
||||
'alt="the machine\'s screen"></div>'
|
||||
f'<div style="padding-top:4px;color:#888">{caption}</div>',
|
||||
"#101010", "screen"))
|
||||
|
||||
# BOX THREE: the command feed, always, at one height. It used to be
|
||||
# halved when a screen was shown above it; the screen is now the
|
||||
# exception rather than the rule, so the feed is not shrunk for
|
||||
# something that may never appear. Clicked, it shows half as many
|
||||
# lines - the newest half, because the rows sit at the bottom of
|
||||
# the box and it is the top that is cut off.
|
||||
if valves.show_terminal:
|
||||
height = TERMINAL_LINES
|
||||
# Exactly `height` rows, always, so the box does not change
|
||||
# size as output arrives; each row clips at the right edge
|
||||
# instead of wrapping or scrolling sideways.
|
||||
lines = terminal[-height:]
|
||||
lines = [""] * (height - len(lines)) + lines
|
||||
# `flex:none` is load-bearing. The rows sit in a flex column so
|
||||
# that a collapsed feed keeps its NEWEST lines against the
|
||||
# bottom and loses the oldest off the top. Flex items shrink by
|
||||
# default, so without this the rows squeezed into the smaller
|
||||
# box and the text overlapped itself instead of overflowing -
|
||||
# measured, twenty lines crushed into ten lines of space.
|
||||
rows = "".join(
|
||||
'<div style="white-space:pre;overflow:hidden;text-overflow:ellipsis;'
|
||||
f'flex:none">{esc(line) or " "}</div>'
|
||||
for line in lines
|
||||
)
|
||||
embeds.append(frame(
|
||||
'<div id="rows" style="display:flex;flex-direction:column;'
|
||||
f'justify-content:flex-end;overflow:hidden">{rows}</div>',
|
||||
"#101010", "feed", n=height))
|
||||
|
||||
await emitter({"type": "embeds", "data": {"replace": True, "embeds": embeds}})
|
||||
Reference in New Issue
Block a user