diff --git a/README.md b/README.md index 44d145e..7f1777d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ FLASH (**F**ast **L**ocal **A**gent **SH**ell) CLI is an AI-powered command-line - Manually execute shell commands using the `!` prefix. - **`flash://` Links**: Open Flash from a browser or another app with a prompt ready to go (`flash://?prompt=What+is+Python`). - **Image Recognition**: Send a local image to a vision-capable model with `/image [prompt]`, or let the AI open one itself with its `view_image` tool. +- **3D Models**: Ask for a chair, a vase, or a floor plan and the AI builds it with its `make_3d_model` tool out of boxes, cylinders, spheres, lathed profiles, extruded outlines, and blocky pixel-font text for logos and signs, saved as a `.glb` that opens in Blender and every game engine. In the web UI it appears in a 3D viewer beside the chat that you can turn, zoom, and download; a vision model also gets a render of it, so it can see a leg floating off the table and fix it. `send_3d_model` shows an existing `.glb`, `.stl`, or `.obj`. - **Page Screenshots**: The AI renders a page it built in a headless browser with its `screenshot` tool and looks at the result, so it can see a broken layout instead of guessing from the HTML. - **Page Control**: The AI opens a page with `open_page` and then clicks buttons, fills forms, presses keys, and runs JavaScript on it with `interact`, seeing a fresh screenshot, the page's elements, and its console errors after every step, so it can debug what a page *does*, not just how it looks. - **Voice Mode**: `/voice on` downloads a Vosk speech model and a Piper voice, then lets you talk to Flash and hear its replies, with typing still available at any time. @@ -514,7 +515,10 @@ lists them all: **Files** the agent makes come to you. It sends a picture with `send_image` and a PDF with `send_pdf`. In the browser, the file slides out in a panel on the right, with its name and size across the top and -buttons to download it or open it in a tab. A card stays in the chat to +buttons to download it or open it in a tab. A 3D model from +`make_3d_model` or `send_3d_model` opens there in a viewer: drag to turn +it, scroll to zoom, right-drag to pan, with buttons to reset the view, +stop it spinning, and see its wireframe. A card stays in the chat to open it again, and `Alt V` shows or hides the panel. Each file is kept as a copy in `~/.flash/web/files`, so a chat still shows its files after the originals are gone. In the terminal, a PDF opens in your PDF diff --git a/flash/ai.py b/flash/ai.py index 5a3eb1f..d8d2540 100644 --- a/flash/ai.py +++ b/flash/ai.py @@ -987,6 +987,58 @@ def _tool_limit_message() -> dict: } +# A reply that ends by saying what it will do next, with no tool call +# to do it: the model saw something to fix (in a render, a test run, a +# screenshot) and wrote the plan down instead of carrying it out. The +# turn would end there with nothing done, so Flash asks it to go on. +_PROMISE = re.compile( + r"\b(?:I['’]ll|I will|I['’]m going to|I am going to|let me|" + r"let['’]s)\s+" + # Waiting on a sub-agent or the user is a right place to stop. + r"(?!know\b|be\b|leave\b|keep\b|stop\b|wait\b|report\b|notify\b" + r"|let you\b|get back\b|check back\b|update you\b|tell you\b)" + r"[a-z]+", + re.IGNORECASE, +) +# Offers and questions leave the next move to the user, rightly. +_OFFER = re.compile( + r"\b(?:if you(?:['’]d)? (?:like|want|prefer)|would you like|" + r"want me to|shall I|should I|let me know|happy to|just say|" + r"can also|could also)\b", + re.IGNORECASE, +) +_SENTENCE_END = re.compile(r"(?<=[.!?:])\s+") +MAX_PROMISE_NUDGES = 2 +PROMISE_NOTE = ( + "Your last reply said what you would do next, but it called no " + "tool, so nothing was done and your turn would end there. Do it now " + "with the tools, and keep going until it is finished and checked. " + "If you have decided not to, say why in one line instead." +) + + +def unkept_promise(text: str) -> bool: + """Whether a reply ends promising work it did not start.""" + + paragraphs = [p for p in re.split(r"\n\s*\n", text.strip()) if p] + if not paragraphs: + return False + # Only how it ends counts: "I'll" early on is usually a summary of + # what was done, while the last words are what happens next. + sentences = _SENTENCE_END.split(paragraphs[-1].strip()) + tail = " ".join(sentences[-2:]) + if tail.rstrip().endswith("?") or _OFFER.search(tail): + return False + return bool(_PROMISE.search(tail)) + + +def promise_nudge() -> dict: + """Flash telling the model to do what it just said it would. A + system message, so it is not kept in the history after the turn.""" + + return {"role": "system", "content": PROMISE_NOTE} + + def _response_parts(response) -> tuple[str, str, list]: message = getattr(response, "message", None) @@ -1334,6 +1386,40 @@ def _chat_retry_until_response( return final, thinking, tool_calls, None +def _chat_until_acted( + console: Console, + client: "ollama.Client", + convo: list, + tools_arg, + nudged: list, + *, + is_image: bool = False, + turn: Optional[Turn] = None, + bar: Optional[Callable[[], str]] = None, +) -> tuple[str, str, list, Optional[str]]: + """`_chat_retry_until_response`, except that a reply promising work + it made no tool call for is shown, and the model is told to do it, + up to MAX_PROMISE_NUDGES times a turn. CONVO grows by that reply + and the nudge; NUDGED, a one-item list, counts the turn's nudges.""" + + while True: + final, thinking, tool_calls, err = _chat_retry_until_response( + console, client, convo, tools_arg, + is_image=is_image, turn=turn, bar=bar, + ) + if (err or tool_calls or not tools_arg + or nudged[0] >= MAX_PROMISE_NUDGES + or not unkept_promise(final)): + return final, thinking, tool_calls, err + nudged[0] += 1 + _render_thinking(thinking) + _render_markdown(console, final) + convo.append(_message("assistant", final)) + convo.append(promise_nudge()) + # Any picture went with the call just made. + is_image = False + + def _context_ceiling() -> Optional[int]: """The longest window the active model could do, asked once.""" @@ -2928,14 +3014,25 @@ def redraw_banner() -> None: def bar() -> Text: return _bar_text(messages) - final, thinking, tool_calls, err = _chat_retry_until_response( - console, client, [system_message] + messages, offered, + nudged = [0] + first = [system_message] + messages + final, thinking, tool_calls, err = _chat_until_acted( + console, client, first, offered, nudged, is_image=bool(pending_images), turn=turn, bar=bar, ) - if err: + if err and not nudged[0]: _print_backend_error(err) messages.pop() continue + # A reply that promised work and was told to do it stays in + # the history, as the user saw it; the nudge does not. + messages.extend( + m for m in first[1 + len(messages):] + if m.get("role") != "system" + ) + if err: + _print_backend_error(err) + continue subagents.mark_delivered(delivered_ids) if not woken: @@ -3009,9 +3106,9 @@ def bar() -> Text: _message("user", TOOL_IMAGE_NOTE, tool_images) ) - final, thinking, tool_calls, err = _chat_retry_until_response( - console, client, tool_messages, offered, turn=turn, - is_image=bool(tool_images), bar=bar, + final, thinking, tool_calls, err = _chat_until_acted( + console, client, tool_messages, offered, nudged, + turn=turn, is_image=bool(tool_images), bar=bar, ) if err: tool_error = err diff --git a/flash/browser.py b/flash/browser.py index 2e25337..2bbcf05 100644 --- a/flash/browser.py +++ b/flash/browser.py @@ -15,6 +15,7 @@ import atexit import json +import re from pathlib import Path from typing import Any, Optional from urllib.parse import urlparse @@ -82,6 +83,65 @@ def resolve_target( return path.resolve().as_uri(), "" +# A headless browser draws no pointer, so the agent's is drawn into the +# page: the Flash cursor, following every move of Playwright's mouse, so +# a screenshot shows where it pointed and what it clicked. It lives in a +# closed shadow root with pointer-events off: nothing on the page can +# style it, find it, or be blocked by it. +_CURSOR_JS = """ +(() => { + if (window.__flashCursor) return; + const svg = ''; + let host = null, x = 0, y = 0, shown = false, pressed = false; + const place = () => { + if (!host) return; + host.style.display = shown ? 'block' : 'none'; + host.style.transform = 'translate(' + (x - 3) + 'px,' + (y - 2) + 'px)' + + (pressed ? ' scale(0.84)' : ''); + }; + const make = () => { + const root = document.documentElement; + if (!root || (host && host.isConnected)) return; + host = document.createElement('flash-cursor'); + host.style.cssText = 'position:fixed;left:0;top:0;width:24px;' + + 'height:24px;z-index:2147483647;pointer-events:none;' + + 'transform-origin:3px 2px;display:none;'; + host.attachShadow({mode: 'closed'}).innerHTML = svg; + root.appendChild(host); + place(); + }; + window.__flashCursor = (nx, ny) => { + x = nx; y = ny; shown = true; make(); place(); + }; + addEventListener('mousemove', (e) => { + window.__flashCursor(e.clientX, e.clientY); + if (window.__flashMoved) window.__flashMoved(e.clientX, e.clientY); + }, true); + addEventListener('mousedown', () => { pressed = true; place(); }, true); + addEventListener('mouseup', () => { pressed = false; place(); }, true); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', make); + } else { + make(); + } +})(); +""" + + +def _point(text: str) -> Optional[tuple[float, float]]: + """An "x,y" the model typed, in the page's pixels, or None.""" + + numbers = re.findall(r"-?\d+(?:\.\d+)?", text or "") + if len(numbers) < 2: + return None + return float(numbers[0]), float(numbers[1]) + + def _watch(page, problems: list[str]) -> None: """Record the page's own errors so a broken render explains itself.""" @@ -127,9 +187,13 @@ def capture( height: int, full_page: bool, wait_ms: int, + mouse: Optional[tuple[float, float]] = None, ) -> tuple[list[str], str]: """Render `url` to `out` as a PNG. + With `mouse`, the pointer is moved there first, so the picture shows + what hovering does, with the Flash cursor where it rests. + Returns `(problems, "")` on success, where `problems` are errors the page itself reported, or `([], reason)` when no screenshot was taken. """ @@ -154,12 +218,17 @@ def capture( viewport={"width": width, "height": height}, ) _watch(page, problems) + page.add_init_script(_CURSOR_JS) page.goto( url, wait_until="load", timeout=NAVIGATION_TIMEOUT_MS, ) _settle(page, wait_ms) + if mouse is not None: + page.mouse.move(*mouse) + # Long enough for a hover's transition to show. + page.wait_for_timeout(HOVER_SETTLE_MS) page.screenshot(path=str(out), full_page=full_page) finally: browser.close() @@ -193,6 +262,14 @@ def _launch_reason(exc: Exception) -> str: # navigation is worth waiting the longer time for. ACTION_TIMEOUT_MS = 5000 +# After the mouse arrives somewhere, before the picture: a hover's own +# transition is usually done by then. +HOVER_SETTLE_MS = 300 + +# How many steps a move or a drag is made in: enough that the page sees +# the pointer travel, as it would a hand's, and not jump. +MOUSE_STEPS = 12 + # A page can carry hundreds of links. Enough of them to work with beats a # list the model has to wade through. MAX_ELEMENTS = 40 @@ -253,6 +330,27 @@ def __init__(self, driver, browser) -> None: self.browser = browser self.page: Any = None self.problems: list[str] = [] + # Where the agent's pointer is, in the page's pixels, once it has + # moved: drawn again on a page it navigated to. + self.mouse: Optional[tuple[float, float]] = None + + def moved(self, x: float, y: float) -> None: + self.mouse = (x, y) + + def redraw_cursor(self) -> None: + """Put the cursor back where the pointer is, on a page that was + loaded since it last moved and so has not drawn it yet.""" + + if self.mouse is None or self.page is None: + return + try: + self.page.evaluate( + "([x, y]) => window.__flashCursor" + " && window.__flashCursor(x, y)", + list(self.mouse), + ) + except Exception: # noqa: BLE001, S110 + pass def drain(self) -> list[str]: """Hand over the errors seen since the last time we asked.""" @@ -323,6 +421,8 @@ def open_page(url: str, *, width: int, height: int, wait_ms: int) -> str: page.set_default_timeout(ACTION_TIMEOUT_MS) session.page = page _watch(page, session.problems) + page.expose_function("__flashMoved", session.moved) + page.add_init_script(_CURSOR_JS) page.goto(url, wait_until="load", timeout=NAVIGATION_TIMEOUT_MS) _settle(page, wait_ms) except PlaywrightError as exc: @@ -370,6 +470,8 @@ def interact( _settle(page, wait_ms, ACTION_TIMEOUT_MS) except (PageProblem, PlaywrightError) as exc: return "", _first_line(exc) + finally: + _session.redraw_cursor() return note, "" @@ -529,11 +631,68 @@ def _locate(page, selector: str): def _act_click(page, selector: str, value: str) -> str: + # No selector, a point: a click where the model sees something in the + # screenshot that no selector names, like a spot on a canvas. + if not selector: + spot = _point(value) + if spot is None: + raise PageProblem( + "click needs a selector, or a point as value: \"x,y\" in " + "the screenshot's pixels." + ) + page.mouse.move(*spot, steps=MOUSE_STEPS) + page.mouse.click(*spot) + + return f"Clicked at {_spot(spot)}." + _locate(page, selector).click(timeout=ACTION_TIMEOUT_MS) return f"Clicked {selector}." +def _spot(point: tuple[float, float]) -> str: + return f"{point[0]:g},{point[1]:g}" + + +def _act_move(page, selector: str, value: str) -> str: + """Move the pointer onto an element, or to a point, and leave it.""" + + if selector: + _locate(page, selector).hover(timeout=ACTION_TIMEOUT_MS) + + return f"Moved the mouse onto {selector}." + + spot = _point(value) + if spot is None: + raise PageProblem( + "move needs a selector, or a point as value: \"x,y\" in the " + "screenshot's pixels." + ) + page.mouse.move(*spot, steps=MOUSE_STEPS) + + return f"Moved the mouse to {_spot(spot)}." + + +def _act_drag(page, selector: str, value: str) -> str: + """Press at one point, move to another, let go: a slider, a canvas, + something dragged into place.""" + + numbers = re.findall(r"-?\d+(?:\.\d+)?", value or "") + if len(numbers) < 4: + raise PageProblem( + "drag needs two points as value: \"x1,y1 x2,y2\" in the " + "screenshot's pixels, from where to where." + ) + start = (float(numbers[0]), float(numbers[1])) + end = (float(numbers[2]), float(numbers[3])) + page.mouse.move(*start, steps=MOUSE_STEPS) + page.mouse.down() + page.mouse.move(*end, steps=MOUSE_STEPS) + page.mouse.up() + + return f"Dragged from {_spot(start)} to {_spot(end)}." + + def _act_fill(page, selector: str, value: str) -> str: if not value: raise PageProblem("fill needs the text to type, in value.") @@ -645,6 +804,8 @@ def _act_reload(page, selector: str, value: str) -> str: _ACTIONS = { "click": _act_click, + "move": _act_move, + "drag": _act_drag, "fill": _act_fill, "press": _act_press, "hover": _act_hover, diff --git a/flash/model3d.py b/flash/model3d.py new file mode 100644 index 0000000..eb939a0 --- /dev/null +++ b/flash/model3d.py @@ -0,0 +1,968 @@ +"""3D models the agent builds out of parts. + +The model describes a model as a list of parts, each a shape (a box, a +sphere, a cylinder, a lathed profile, an extruded outline, blocky text, +or a mesh of its own) with a place, a turn, a size and a colour. This +turns that list into a binary glTF (.glb) file: one file that the web +UI's viewer, Blender, Windows' 3D Viewer and every game engine open as +it is. + +Pure Python on purpose, with no numpy or trimesh to install, since a +model of a few hundred parts is a few hundred thousand floats at most. +""" + +import base64 +import json +import math +import re +import struct +from pathlib import Path +from typing import Any, Optional + +# Every shape is built around the origin, Y up, in metres, as glTF has it. +SHAPES = ( + "box", "sphere", "cylinder", "cone", "torus", "plane", "lathe", + "extrude", "mesh", "text", +) +MODEL_SUFFIXES = (".glb", ".stl", ".obj") + +MAX_PARTS = 500 +MAX_TRIANGLES = 2_000_000 +MAX_POINTS = 5000 +MIN_SEGMENTS = 3 +MAX_SEGMENTS = 128 +DEFAULT_SEGMENTS = 32 + +DEFAULT_COLOR = "#b0b4bb" + +# The CSS names a model reaches for first. Anything else is a hex code. +NAMED_COLORS = { + "black": "#000000", "white": "#ffffff", "gray": "#808080", + "grey": "#808080", "silver": "#c0c0c0", "red": "#e53935", + "darkred": "#8b0000", "orange": "#fb8c00", "yellow": "#fdd835", + "gold": "#ffd700", "green": "#43a047", "darkgreen": "#1b5e20", + "lime": "#7cb342", "teal": "#00897b", "cyan": "#00bcd4", + "blue": "#1e88e5", "navy": "#1a237e", "skyblue": "#87ceeb", + "purple": "#8e24aa", "violet": "#ee82ee", "pink": "#f48fb1", + "magenta": "#d81b60", "brown": "#795548", "tan": "#d2b48c", + "beige": "#f5f5dc", "wood": "#a0522d", "glass": "#cfe8ff", +} +_HEX = re.compile(r"^#?([0-9a-f]{3}|[0-9a-f]{6})$", re.IGNORECASE) + +_ARRAY_BUFFER = 34962 +_ELEMENT_ARRAY_BUFFER = 34963 +_FLOAT = 5126 +_UNSIGNED_INT = 5125 + + +class ModelError(ValueError): + """A part the model described that cannot be built, and why.""" + + +class Geometry: + """Triangles with a normal at every corner, ready to write out.""" + + def __init__(self) -> None: + self.positions: list[tuple[float, float, float]] = [] + self.normals: list[tuple[float, float, float]] = [] + self.indices: list[int] = [] + + def vertex(self, p, n) -> int: + self.positions.append((float(p[0]), float(p[1]), float(p[2]))) + self.normals.append(_unit(n)) + return len(self.positions) - 1 + + def triangle(self, a: int, b: int, c: int) -> None: + self.indices.extend((a, b, c)) + + def flat(self, a, b, c) -> None: + """One triangle with its own corners, lit flat.""" + + n = _cross(_sub(b, a), _sub(c, a)) + if _length(n) == 0: + return + self.triangle(self.vertex(a, n), self.vertex(b, n), self.vertex(c, n)) + + @property + def triangles(self) -> int: + return len(self.indices) // 3 + + +# --- Vector arithmetic ---------------------------------------------------- + + +def _sub(a, b): + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def _cross(a, b): + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def _length(v) -> float: + return math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) + + +def _unit(v): + size = _length(v) + if size == 0: + return (0.0, 1.0, 0.0) + return (v[0] / size, v[1] / size, v[2] / size) + + +# --- Reading what the model gave ------------------------------------------ + + +def _number(value: Any, what: str, *, positive: bool = False) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ModelError(f"{what} must be a number, not {value!r}") from None + if not math.isfinite(number): + raise ModelError(f"{what} must be a finite number") + if positive and number <= 0: + raise ModelError(f"{what} must be more than 0, not {number:g}") + return number + + +def _vector(value: Any, what: str, fallback, *, positive=False): + """Three numbers, from a list or from one number used for all three.""" + + if value is None: + return fallback + if isinstance(value, (int, float, str)): + one = _number(value, what, positive=positive) + return (one, one, one) + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise ModelError(f"{what} must be [x, y, z]") + return tuple( + _number(v, f"{what}[{i}]", positive=positive) + for i, v in enumerate(value) + ) + + +def _points(value: Any, what: str, size: int) -> list[tuple]: + if not isinstance(value, (list, tuple)) or not value: + raise ModelError(f"{what} must be a list of points") + if len(value) > MAX_POINTS: + raise ModelError(f"{what} has over {MAX_POINTS} points") + points = [] + for i, point in enumerate(value): + if not isinstance(point, (list, tuple)) or len(point) != size: + raise ModelError( + f"{what}[{i}] must be {size} numbers, not {point!r}" + ) + points.append(tuple( + _number(v, f"{what}[{i}]") for v in point + )) + return points + + +def _segments(value: Any, fallback: int = DEFAULT_SEGMENTS) -> int: + try: + number = int(value) + except (TypeError, ValueError): + return fallback + return max(MIN_SEGMENTS, min(MAX_SEGMENTS, number)) + + +def parse_color(value: Any) -> tuple[float, float, float]: + """A colour as sRGB 0..1, from a hex code or a common name.""" + + text = str(value or DEFAULT_COLOR).strip().lower().replace(" ", "") + text = NAMED_COLORS.get(text, text) + found = _HEX.match(text) + if not found: + raise ModelError( + f"color {value!r} is not a hex code like #ff8800 or a common " + "colour name" + ) + digits = found.group(1) + if len(digits) == 3: + digits = "".join(d * 2 for d in digits) + return tuple(int(digits[i:i + 2], 16) / 255 for i in (0, 2, 4)) + + +def _linear(channel: float) -> float: + """sRGB to the linear light glTF stores its colours in.""" + + if channel <= 0.04045: + return channel / 12.92 + return ((channel + 0.055) / 1.055) ** 2.4 + + +def _fraction(value: Any, what: str, fallback: float) -> float: + if value is None: + return fallback + return max(0.0, min(1.0, _number(value, what))) + + +# --- The shapes ----------------------------------------------------------- + + +# A box's faces, each as its normal and two edges, walked +# counter-clockwise as seen from outside. +_BOX_FACES = ( + ((1, 0, 0), (0, 0, -1), (0, 1, 0)), + ((-1, 0, 0), (0, 0, 1), (0, 1, 0)), + ((0, 1, 0), (1, 0, 0), (0, 0, -1)), + ((0, -1, 0), (1, 0, 0), (0, 0, 1)), + ((0, 0, 1), (1, 0, 0), (0, 1, 0)), + ((0, 0, -1), (-1, 0, 0), (0, 1, 0)), +) + + +def _add_box(geo: Geometry, middle, half, hidden=()) -> None: + """A box around MIDDLE, HALF its size each way, leaving out the + faces whose normals are in HIDDEN, where another box touches it.""" + + for normal, u, v in _BOX_FACES: + if normal in hidden: + continue + centre = tuple(middle[i] + normal[i] * half[i] for i in range(3)) + corners = [] + for a, b in ((-1, -1), (1, -1), (1, 1), (-1, 1)): + corners.append(tuple( + centre[i] + a * u[i] * half[i] + b * v[i] * half[i] + for i in range(3) + )) + first = [geo.vertex(c, normal) for c in corners] + geo.triangle(first[0], first[1], first[2]) + geo.triangle(first[0], first[2], first[3]) + + +def _box(part: dict) -> Geometry: + sx, sy, sz = _vector(part.get("size"), "size", (1.0, 1.0, 1.0), + positive=True) + geo = Geometry() + _add_box(geo, (0, 0, 0), (sx / 2, sy / 2, sz / 2)) + return geo + + +# A blocky 5x7 font for the text shape, "#" for a block, drawn top row +# first. Narrow marks are narrower rows. +FONT = { + "A": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"), + "B": ("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."), + "C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."), + "D": ("####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."), + "E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"), + "F": ("#####", "#....", "#....", "####.", "#....", "#....", "#...."), + "G": (".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."), + "H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"), + "I": ("###", ".#.", ".#.", ".#.", ".#.", ".#.", "###"), + "J": ("..###", "...#.", "...#.", "...#.", "#..#.", "#..#.", ".##.."), + "K": ("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"), + "L": ("#....", "#....", "#....", "#....", "#....", "#....", "#####"), + "M": ("#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#", "#...#"), + "N": ("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"), + "O": (".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), + "P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."), + "Q": (".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"), + "R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"), + "S": (".####", "#....", "#....", ".###.", "....#", "....#", "####."), + "T": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."), + "U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), + "V": ("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."), + "W": ("#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#"), + "X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"), + "Y": ("#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."), + "Z": ("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"), + "0": (".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."), + "1": ("..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."), + "2": (".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"), + "3": ("####.", "....#", "....#", ".###.", "....#", "....#", "####."), + "4": ("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."), + "5": ("#####", "#....", "####.", "....#", "....#", "#...#", ".###."), + "6": (".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."), + "7": ("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."), + "8": (".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."), + "9": (".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."), + " ": ("...",) * 7, + ".": (".", ".", ".", ".", ".", ".", "#"), + ",": (".", ".", ".", ".", ".", "#", "#"), + "!": ("#", "#", "#", "#", "#", ".", "#"), + "?": (".###.", "#...#", "....#", "...#.", "..#..", ".....", "..#.."), + "-": ("...", "...", "...", "###", "...", "...", "..."), + ":": (".", "#", ".", ".", ".", "#", "."), + "'": ("#", "#", ".", ".", ".", ".", "."), + "&": (".##..", "#..#.", "#.#..", ".#...", "#.#.#", "#..#.", ".##.#"), + "/": ("....#", "...#.", "...#.", "..#..", ".#...", ".#...", "#...."), +} +FONT_ROWS = 7 +MAX_TEXT = 200 + + +def _text(part: dict) -> Geometry: + """Words in blocks, one block to a pixel of the font, reading along + X and facing +Z: a logo, a sign, a name over a door.""" + + words = str(part.get("text") or "").upper() + if not words.strip(): + raise ModelError("a text part needs text") + if len(words) > MAX_TEXT: + raise ModelError(f"a text part can have at most {MAX_TEXT} letters") + unknown = sorted({c for c in words if c not in FONT and c != "\n"}) + if unknown: + raise ModelError( + f"the font has no {' '.join(repr(c) for c in unknown)}; it has " + "A-Z, 0-9, spaces and . , ! ? - : ' & /" + ) + # The height of a capital, and the block that makes it. + height = _number(part.get("height", part.get("size", 1)), "height", + positive=True) + block = height / FONT_ROWS + depth = _number(part.get("depth", block), "depth", positive=True) + + # Each line as columns of blocks, a column of space between letters. + lines = [] + for line in words.split("\n"): + columns: list[str] = [] + for n, char in enumerate(line): + if n: + columns.append("." * FONT_ROWS) + rows = FONT[char] + for x in range(len(rows[0])): + columns.append("".join(row[x] for row in rows)) + lines.append(columns) + widest = max(len(columns) for columns in lines) + # Lines two blocks apart, each centred. + tall = len(lines) * (FONT_ROWS + 2) - 2 + filled = set() + for k, columns in enumerate(lines): + left = (widest - len(columns)) // 2 + top = tall - 1 - k * (FONT_ROWS + 2) + for x, column in enumerate(columns): + for y, pixel in enumerate(column): + if pixel == "#": + filled.add((left + x, top - y)) + if not filled: + raise ModelError("a text part needs at least one letter") + + geo = Geometry() + half = (block / 2, block / 2, depth / 2) + for x, y in filled: + hidden = tuple( + normal for normal, step in ( + ((1, 0, 0), (1, 0)), ((-1, 0, 0), (-1, 0)), + ((0, 1, 0), (0, 1)), ((0, -1, 0), (0, -1)), + ) + if (x + step[0], y + step[1]) in filled + ) + middle = ( + (x + 0.5 - widest / 2) * block, + (y + 0.5 - tall / 2) * block, + 0.0, + ) + _add_box(geo, middle, half, hidden) + return geo + + +def _plane(part: dict) -> Geometry: + """A flat rectangle facing up: a floor, a table top, a sheet.""" + + size = part.get("size") + if isinstance(size, (list, tuple)) and len(size) == 2: + sx = _number(size[0], "size[0]", positive=True) + sz = _number(size[1], "size[1]", positive=True) + else: + sx, _, sz = _vector(size, "size", (1.0, 1.0, 1.0), positive=True) + x, z = sx / 2, sz / 2 + geo = Geometry() + up = (0, 1, 0) + a, b, c, d = (geo.vertex(p, up) for p in ( + (-x, 0, z), (x, 0, z), (x, 0, -z), (-x, 0, -z), + )) + geo.triangle(a, b, c) + geo.triangle(a, c, d) + return geo + + +def _sphere(part: dict) -> Geometry: + radius = _number(part.get("radius", 0.5), "radius", positive=True) + around = _segments(part.get("segments")) + down = max(MIN_SEGMENTS, around // 2) + geo = Geometry() + rows = [] + for j in range(down + 1): + polar = math.pi * j / down + row = [] + for i in range(around + 1): + azimuth = 2 * math.pi * i / around + n = ( + -math.sin(polar) * math.cos(azimuth), + math.cos(polar), + math.sin(polar) * math.sin(azimuth), + ) + row.append(geo.vertex(tuple(radius * c for c in n), n)) + rows.append(row) + for j in range(down): + for i in range(around): + a, b = rows[j][i], rows[j][i + 1] + c, d = rows[j + 1][i], rows[j + 1][i + 1] + if j != 0: + geo.triangle(a, d, b) + if j != down - 1: + geo.triangle(a, c, d) + return geo + + +def _revolve(geo: Geometry, profile: list[tuple], around: int) -> None: + """Spin a profile of (radius, y) points around the Y axis, with the + normals of the surface it sweeps, smooth all the way round.""" + + count = len(profile) + # Each point's normal in the profile's plane: across the segments + # either side of it, turned outwards. + flat_normals = [] + for k in range(count): + before = profile[max(0, k - 1)] + after = profile[min(count - 1, k + 1)] + dr, dy = after[0] - before[0], after[1] - before[1] + flat_normals.append((dy, -dr)) + rings = [] + for k, (r, y) in enumerate(profile): + nr, ny = flat_normals[k] + ring = [] + for i in range(around + 1): + angle = 2 * math.pi * i / around + cos, sin = math.cos(angle), -math.sin(angle) + ring.append(geo.vertex( + (r * cos, y, r * sin), (nr * cos, ny, nr * sin), + )) + rings.append(ring) + for k in range(count - 1): + for i in range(around): + a, b = rings[k][i], rings[k][i + 1] + c, d = rings[k + 1][i], rings[k + 1][i + 1] + if profile[k][0] > 0: + geo.triangle(a, b, c) + if profile[k + 1][0] > 0: + geo.triangle(b, d, c) + + +def _cap(geo: Geometry, radius: float, y: float, around: int, + up: bool) -> None: + if radius <= 0: + return + normal = (0, 1 if up else -1, 0) + centre = geo.vertex((0, y, 0), normal) + edge = [] + for i in range(around + 1): + angle = 2 * math.pi * i / around + edge.append(geo.vertex( + (radius * math.cos(angle), y, -radius * math.sin(angle)), normal, + )) + for i in range(around): + if up: + geo.triangle(centre, edge[i], edge[i + 1]) + else: + geo.triangle(centre, edge[i + 1], edge[i]) + + +def _cylinder(part: dict, *, cone: bool = False) -> Geometry: + height = _number(part.get("height", 1), "height", positive=True) + radius = part.get("radius", 0.5) + bottom = _number(part.get("radius_bottom", radius), "radius_bottom") + top = 0.0 if cone else _number(part.get("radius_top", radius), + "radius_top") + if bottom < 0 or top < 0 or bottom == top == 0: + raise ModelError("a cylinder needs a radius more than 0") + around = _segments(part.get("segments")) + geo = Geometry() + h = height / 2 + _revolve(geo, [(bottom, -h), (top, h)], around) + _cap(geo, top, h, around, up=True) + _cap(geo, bottom, -h, around, up=False) + return geo + + +def _torus(part: dict) -> Geometry: + """A ring lying flat, its hole along Y, like a donut on a plate.""" + + radius = _number(part.get("radius", 0.5), "radius", positive=True) + tube = _number(part.get("tube", radius / 4), "tube", positive=True) + around = _segments(part.get("segments"), 48) + sides = max(MIN_SEGMENTS, around // 2) + geo = Geometry() + rows = [] + for j in range(sides + 1): + v = 2 * math.pi * j / sides + row = [] + for i in range(around + 1): + u = 2 * math.pi * i / around + ring = (math.cos(u), 0.0, -math.sin(u)) + n = ( + ring[0] * math.cos(v), math.sin(v), ring[2] * math.cos(v), + ) + p = tuple(radius * ring[k] + tube * n[k] for k in range(3)) + row.append(geo.vertex(p, n)) + rows.append(row) + for j in range(sides): + for i in range(around): + a, b = rows[j][i], rows[j][i + 1] + c, d = rows[j + 1][i], rows[j + 1][i + 1] + geo.triangle(a, b, d) + geo.triangle(a, d, c) + return geo + + +def _lathe(part: dict) -> Geometry: + """A profile of [radius, y] points, bottom to top, spun around Y: + a vase, a bottle, a lamp, a chess piece.""" + + profile = _points(part.get("points"), "points", 2) + if len(profile) < 2: + raise ModelError("a lathe needs at least 2 points") + if any(r < 0 for r, _ in profile): + raise ModelError("a lathe's radii cannot be negative") + # Walked the other way, the surface would face inwards. + if profile[-1][1] < profile[0][1]: + profile.reverse() + geo = Geometry() + around = _segments(part.get("segments"), 48) + _revolve(geo, profile, around) + if part.get("closed", True): + _cap(geo, profile[-1][0], profile[-1][1], around, up=True) + _cap(geo, profile[0][0], profile[0][1], around, up=False) + return geo + + +def _signed_area(points: list[tuple]) -> float: + area = 0.0 + for i, (x1, y1) in enumerate(points): + x2, y2 = points[(i + 1) % len(points)] + area += x1 * y2 - x2 * y1 + return area / 2 + + +def _inside(p, a, b, c) -> bool: + def side(p1, p2, p3): + return (p1[0] - p3[0]) * (p2[1] - p3[1]) - \ + (p2[0] - p3[0]) * (p1[1] - p3[1]) + + d1, d2, d3 = side(p, a, b), side(p, b, c), side(p, c, a) + negative = d1 < 0 or d2 < 0 or d3 < 0 + positive = d1 > 0 or d2 > 0 or d3 > 0 + return not (negative and positive) + + +def triangulate(points: list[tuple]) -> list[tuple[int, int, int]]: + """Ear-clip a simple polygon, convex or not, counter-clockwise.""" + + order = list(range(len(points))) + if _signed_area(points) < 0: + order.reverse() + found = [] + guard = 0 + while len(order) > 3 and guard < len(points) ** 2: + guard += 1 + for k in range(len(order)): + i, j, m = order[k - 1], order[k], order[(k + 1) % len(order)] + a, b, c = points[i], points[j], points[m] + turn = (b[0] - a[0]) * (c[1] - a[1]) - \ + (b[1] - a[1]) * (c[0] - a[0]) + if turn <= 0: + continue + if any(_inside(points[o], a, b, c) + for o in order if o not in (i, j, m)): + continue + found.append((i, j, m)) + order.pop(k) + break + else: + break + if len(order) == 3: + found.append(tuple(order)) + return found + + +def _extrude(part: dict) -> Geometry: + """An outline of [x, z] points, seen from above, raised to a height: + a floor plan's walls, a letter, a gear, a sign.""" + + outline = _points(part.get("points"), "points", 2) + if len(outline) < 3: + raise ModelError("an extrude needs at least 3 points") + if outline[0] == outline[-1]: + outline = outline[:-1] + height = _number(part.get("height", 1), "height", positive=True) + if abs(_signed_area(outline)) == 0: + raise ModelError("an extrude's outline has no area") + # Walls face out when the outline turns from x towards z. + if _signed_area(outline) < 0: + outline = outline[::-1] + h = height / 2 + geo = Geometry() + ears = triangulate(outline) + for i, j, m in ears: + a, b, c = outline[i], outline[j], outline[m] + geo.flat((a[0], h, a[1]), (c[0], h, c[1]), (b[0], h, b[1])) + geo.flat((a[0], -h, a[1]), (b[0], -h, b[1]), (c[0], -h, c[1])) + for k, (x1, z1) in enumerate(outline): + x2, z2 = outline[(k + 1) % len(outline)] + low1, low2 = (x1, -h, z1), (x2, -h, z2) + high1, high2 = (x1, h, z1), (x2, h, z2) + geo.flat(low1, high1, high2) + geo.flat(low1, high2, low2) + return geo + + +def _mesh(part: dict) -> Geometry: + """Any shape at all: vertices and the faces between them.""" + + vertices = _points(part.get("vertices"), "vertices", 3) + faces = part.get("faces") + if not isinstance(faces, list) or not faces: + raise ModelError("a mesh needs faces: lists of vertex indices") + if len(faces) > MAX_TRIANGLES: + raise ModelError(f"a mesh can have at most {MAX_TRIANGLES} faces") + geo = Geometry() + for n, face in enumerate(faces): + if not isinstance(face, (list, tuple)) or len(face) < 3: + raise ModelError(f"faces[{n}] must list 3 or more vertices") + try: + corners = [vertices[int(i)] for i in face] + except (IndexError, TypeError, ValueError): + raise ModelError( + f"faces[{n}] names a vertex that is not in vertices " + f"(there are {len(vertices)}, numbered from 0)" + ) from None + for k in range(1, len(corners) - 1): + geo.flat(corners[0], corners[k], corners[k + 1]) + return geo + + +_BUILDERS = { + "box": _box, + "plane": _plane, + "sphere": _sphere, + "cylinder": _cylinder, + "cone": lambda part: _cylinder(part, cone=True), + "torus": _torus, + "lathe": _lathe, + "extrude": _extrude, + "mesh": _mesh, + "text": _text, +} + + +# --- Placing the parts ---------------------------------------------------- + + +def _quaternion(degrees) -> tuple[float, float, float, float]: + """Euler angles in degrees, turned X then Y then Z as three.js does, + as the [x, y, z, w] quaternion glTF stores.""" + + x, y, z = (math.radians(d) / 2 for d in degrees) + cx, sx = math.cos(x), math.sin(x) + cy, sy = math.cos(y), math.sin(y) + cz, sz = math.cos(z), math.sin(z) + return ( + sx * cy * cz + cx * sy * sz, + cx * sy * cz - sx * cy * sz, + cx * cy * sz + sx * sy * cz, + cx * cy * cz - sx * sy * sz, + ) + + +def _rotate(q, v): + qx, qy, qz, qw = q + tx = 2 * (qy * v[2] - qz * v[1]) + ty = 2 * (qz * v[0] - qx * v[2]) + tz = 2 * (qx * v[1] - qy * v[0]) + return ( + v[0] + qw * tx + (qy * tz - qz * ty), + v[1] + qw * ty + (qz * tx - qx * tz), + v[2] + qw * tz + (qx * ty - qy * tx), + ) + + +class Part: + """One part, built and placed.""" + + def __init__(self, index: int, spec: Any) -> None: + if not isinstance(spec, dict): + raise ModelError(f"part {index + 1} must be an object") + where = f"part {index + 1}" + self.name = str(spec.get("name") or "").strip()[:80] + if self.name: + where += f" ({self.name})" + shape = str(spec.get("shape") or "").strip().lower() + if shape not in _BUILDERS: + raise ModelError( + f"{where}: shape must be one of {', '.join(SHAPES)}, not " + f"{spec.get('shape')!r}" + ) + self.shape = shape + self.name = self.name or f"{shape}-{index + 1}" + try: + self.geometry = _BUILDERS[shape](spec) + self.position = _vector(spec.get("position"), "position", + (0.0, 0.0, 0.0)) + turn = _vector(spec.get("rotation"), "rotation", (0.0, 0.0, 0.0)) + self.rotation = _quaternion(turn) + self.scale = _vector(spec.get("scale"), "scale", + (1.0, 1.0, 1.0), positive=True) + self.color = parse_color(spec.get("color")) + self.metalness = _fraction(spec.get("metalness"), "metalness", + 0.0) + self.roughness = _fraction(spec.get("roughness"), "roughness", + 0.6) + self.opacity = _fraction(spec.get("opacity"), "opacity", 1.0) + self.emissive = spec.get("emissive") + if self.emissive is not None: + self.emissive = parse_color(self.emissive) + except ModelError as exc: + raise ModelError(f"{where}: {exc}") from None + if not self.geometry.indices: + raise ModelError(f"{where}: its shape came out with no faces") + # A mesh's faces run whichever way the model wrote them, so both + # sides of them are drawn. + self.two_sided = shape in ("mesh", "plane") + + def world_points(self): + for p in self.geometry.positions: + scaled = (p[0] * self.scale[0], p[1] * self.scale[1], + p[2] * self.scale[2]) + turned = _rotate(self.rotation, scaled) + yield (turned[0] + self.position[0], + turned[1] + self.position[1], + turned[2] + self.position[2]) + + def material_key(self) -> tuple: + return (self.color, self.metalness, self.roughness, self.opacity, + self.emissive, self.two_sided) + + +def build_parts(parts: Any) -> list[Part]: + """Every part built, or a ModelError naming the first bad one.""" + + if isinstance(parts, str): + try: + parts = json.loads(parts) + except json.JSONDecodeError as exc: + raise ModelError(f"parts is not valid JSON: {exc}") from None + if isinstance(parts, dict): + parts = parts.get("parts", [parts]) + if not isinstance(parts, list) or not parts: + raise ModelError("parts must be a list with at least one part") + if len(parts) > MAX_PARTS: + raise ModelError(f"a model can have at most {MAX_PARTS} parts") + built = [Part(i, spec) for i, spec in enumerate(parts)] + triangles = sum(p.geometry.triangles for p in built) + if triangles > MAX_TRIANGLES: + raise ModelError( + f"the model has {triangles} triangles; the limit is " + f"{MAX_TRIANGLES}, so use fewer segments or fewer parts" + ) + return built + + +# --- Writing it out ------------------------------------------------------- + + +def _pad(data: bytes, filler: bytes) -> bytes: + return data + filler * (-len(data) % 4) + + +def to_glb(parts: list[Part], title: str = "") -> bytes: + """The parts as one binary glTF 2.0 file.""" + + binary = bytearray() + views: list[dict] = [] + accessors: list[dict] = [] + materials: list[dict] = [] + material_of: dict[tuple, int] = {} + meshes: list[dict] = [] + nodes: list[dict] = [] + + def add(data: bytes, target: int) -> int: + views.append({ + "buffer": 0, "byteOffset": len(binary), + "byteLength": len(data), "target": target, + }) + binary.extend(_pad(data, b"\x00")) + return len(views) - 1 + + for part in parts: + geo = part.geometry + flat = [c for p in geo.positions for c in p] + position = add(struct.pack(f"<{len(flat)}f", *flat), _ARRAY_BUFFER) + lows = [min(p[i] for p in geo.positions) for i in range(3)] + highs = [max(p[i] for p in geo.positions) for i in range(3)] + accessors.append({ + "bufferView": position, "componentType": _FLOAT, + "count": len(geo.positions), "type": "VEC3", + "min": lows, "max": highs, + }) + flat = [c for n in geo.normals for c in n] + normal = add(struct.pack(f"<{len(flat)}f", *flat), _ARRAY_BUFFER) + accessors.append({ + "bufferView": normal, "componentType": _FLOAT, + "count": len(geo.normals), "type": "VEC3", + }) + index = add(struct.pack(f"<{len(geo.indices)}I", *geo.indices), + _ELEMENT_ARRAY_BUFFER) + accessors.append({ + "bufferView": index, "componentType": _UNSIGNED_INT, + "count": len(geo.indices), "type": "SCALAR", + }) + + key = part.material_key() + if key not in material_of: + material = { + "pbrMetallicRoughness": { + "baseColorFactor": [ + *(_linear(c) for c in part.color), part.opacity, + ], + "metallicFactor": part.metalness, + "roughnessFactor": part.roughness, + }, + } + if part.opacity < 1: + material["alphaMode"] = "BLEND" + if part.emissive is not None: + material["emissiveFactor"] = [ + _linear(c) for c in part.emissive + ] + if part.two_sided: + material["doubleSided"] = True + material_of[key] = len(materials) + materials.append(material) + + meshes.append({ + "name": part.name, + "primitives": [{ + "attributes": { + "POSITION": len(accessors) - 3, + "NORMAL": len(accessors) - 2, + }, + "indices": len(accessors) - 1, + "material": material_of[key], + }], + }) + node: dict[str, Any] = {"name": part.name, "mesh": len(meshes) - 1} + if any(part.position): + node["translation"] = list(part.position) + if part.rotation != (0.0, 0.0, 0.0, 1.0): + node["rotation"] = list(part.rotation) + if part.scale != (1.0, 1.0, 1.0): + node["scale"] = list(part.scale) + nodes.append(node) + + document: dict[str, Any] = { + "asset": {"version": "2.0", "generator": "Flash"}, + "scene": 0, + "scenes": [{"name": title or "Model", + "nodes": list(range(len(nodes)))}], + "nodes": nodes, + "meshes": meshes, + "materials": materials, + "accessors": accessors, + "bufferViews": views, + "buffers": [{"byteLength": len(binary)}], + } + json_chunk = _pad(json.dumps(document, separators=(",", ":")) + .encode("utf-8"), b" ") + bin_chunk = bytes(binary) + total = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk) + return b"".join(( + struct.pack("<4sII", b"glTF", 2, total), + struct.pack(" str: + """What was built, in numbers the model can check its intent against.""" + + low, high = bounds(parts) + size = [high[i] - low[i] for i in range(3)] + triangles = sum(p.geometry.triangles for p in parts) + + def fmt(values) -> str: + return ", ".join(f"{v:.3g}" for v in values) + + lines = [ + f"{len(parts)} part{'s' if len(parts) != 1 else ''}, " + f"{triangles} triangles.", + f"Overall size (x, y, z): {fmt(size)}, from ({fmt(low)}) to " + f"({fmt(high)}).", + ] + if low[1] < -1e-6 or low[1] > 1e-3: + lines.append( + f"Its lowest point is at y = {low[1]:.3g}, so it does not sit " + "on the ground (y = 0); move it if it should." + ) + return "\n".join(lines) + + +def is_glb(data: bytes) -> bool: + return len(data) >= 12 and data[:4] == b"glTF" + + +def check_model_file(path: Path) -> Optional[str]: + """Why a model file cannot be shown, or None when it can.""" + + suffix = path.suffix.lower() + if suffix not in MODEL_SUFFIXES: + return ( + f"{path.name} is not a .glb, .stl, or .obj file; convert a " + ".gltf with its separate files into one .glb first" + ) + try: + with open(path, "rb") as handle: + head = handle.read(84) + except OSError as exc: + return f"could not read {path}: {exc}" + if suffix == ".glb" and not is_glb(head): + return f"{path.name} does not start like a binary glTF file" + if not head: + return f"{path.name} is empty" + return None + + +# --- A picture of it ------------------------------------------------------ + + +def preview_page(model: bytes, suffix: str, viewer_js: str, + three_js: str) -> str: + """A page that draws the model with the web UI's own viewer, for the + headless browser to take a picture of.""" + + data = base64.b64encode(model).decode("ascii") + # Inlined, a script must not close its own tag early. + three_js, viewer_js = ( + js.replace("" + f"" + "
" + ) diff --git a/flash/system_prompt.txt b/flash/system_prompt.txt index a541b5d..e7acdcc 100644 --- a/flash/system_prompt.txt +++ b/flash/system_prompt.txt @@ -42,11 +42,11 @@ A new file too long for one call is written in pieces: the first with no `append == Sending what you make == The user sees only what you send. `view_image` and `screenshot` show a file to you, never to them, and an image in a tool result is one you looked at, not one the user gave you. -When you make an image, a PDF, or a web page for the user, send it the moment it is finished, without being asked: `send_image` for a picture, `send_pdf` for a PDF, `send_html` for a page. Every revision is a new version: send it again each time, and never just view it and describe the change. +When you make an image, a PDF, a web page, a document, or a 3D model for the user, send it the moment it is finished, without being asked: `send_image` for a picture, `send_pdf` for a PDF, `send_html` for a page, `send_document` for Markdown or text. A 3D model is made and shown in one step with `make_3d_model`; use `send_3d_model` for a .glb, .stl, or .obj file that already exists. The user can edit a document you send and comment on it; when they do, read the file again before you change it, and answer each comment. When a document you send has unfinished parts, gaps, or open questions, say so in comments on those passages rather than leaving them to be found. Every revision is a new version: send it again each time, and never just view it and describe the change. Check before you send, and check for the exact problem the user reported (overlapping lines, a wrong arrow, cut-off text). If you can still see it, or cannot tell, say so plainly. Never tell the user a problem is fixed because you changed the code meant to fix it: it is fixed when you looked and it was gone. -Revise in place: `edit` the script that makes the file and overwrite the same output, instead of writing numbered copies (v2, fixed, final_v8) that pile up. Scripts and drafts belong in the scratch directory; only the finished file goes where the user wants it. A file that is part of a codebase you are editing (a component, a template, a source file) is not a deliverable: don't send those. +Revise in place: `edit` the script that makes the file and overwrite the same output, instead of writing numbered copies (v2, fixed, final_v8) that pile up. Scripts and drafts belong in the scratch directory; only the finished file goes to Downloads, or wherever the user asked. A file that is part of a codebase you are editing (a component, a template, a source file) is not a deliverable: don't send those. == Reading a web page (fetch) == `fetch` takes an http:// or https:// URL and returns the page as text. It is the cheap way to read what a page says: documentation, an article, a README, a changelog, an API reference, a raw file, a JSON endpoint (non-HTML content comes back untouched). Follow a `web_search` result by fetching the URL it gave you, because a search snippet is an advertisement for a page and the qualifier that changes your answer is usually inside the page itself. @@ -126,11 +126,14 @@ Sending a natural-language reply ends your turn, so gather everything you need b When a question needs working out (math, logic, a tricky bug), work it out step by step in your reply before you give the answer, and put the answer last. An answer written first and justified after is a guess. -== Scratch directory == -Every dummy, sample, test, placeholder, or throwaway file goes in the scratch directory, always, even when the user asked you to make it: a file to try a command on, sample data, a test image, a quick script to check something. Only a location the user names changes that. -Write it by its full path inside the scratch directory, in every tool and every shell command. A bare file name, with no folder in front, lands in the working directory, which is the user's project. +== Where files go == +Every file you write goes in one of three places. A place the user names always wins. -Your scratch directory path is given verbatim in the Tool System Prompt below: copy it exactly, character for character, and never look it up. If a write there fails, the path is wrong: copy it again, never write into the user's project instead. Put downloads and intermediate output there too, never in the user's project, home directory, or working directory, and delete each temp file with `shell` as soon as you're done with it, before you reply, unless the user wants to look at it. Files the user asked for to keep and use (a script for their project, a config, a report) are real output: write those where they belong and leave them. If the destination is unclear and the working directory is too broad to write into (a home directory, a filesystem root), use a sensible default such as Downloads, or ask. +1. The scratch directory, for everything that helps you work: test scripts, a quick script to check something, the script that draws a chart or builds a document, sample data, a file to try a command on, drafts, files you download along the way, and any other dummy, sample, test, placeholder, or throwaway file, even one the user asked you to make. Delete each one with `shell` once you are done with it, before you reply, unless the user wants to look at it. +2. The user's Downloads folder, for a finished thing the user asked you to make: a picture, chart, PDF, document, spreadsheet, web page, 3D model, or a standalone script or program for them to keep. Give it a short, clear name, and pick a new name rather than overwrite a file of theirs already there; a new version of something you made in this chat replaces your own earlier file. +3. The project, for changes to the codebase you are working in: its source, its config, and tests the user asked you to add to its test suite. Put those where the project keeps them. + +The scratch directory's path is given verbatim in the Tool System Prompt below: copy it exactly, character for character, and never look it up. The Downloads folder you find yourself, once per session, before the first file you save there: with `shell`, ask the system where it is rather than guessing (on Windows it is often moved, into OneDrive or onto another drive, and `(New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path` gives it; on Linux, `xdg-user-dir DOWNLOAD`, which may be a name in the user's language; on macOS, `~/Downloads`), and create it if it is missing. Write every file by its full path, in every tool and every shell command. A bare file name, with no folder in front, lands in the working directory, which is the user's project; never leave scripts, tests, or output there by accident. If a write to the scratch directory fails, the path is wrong: copy it again, never write into the user's project instead. When you finish, tell the user the full path of anything you saved for them. == Charts and diagrams == Making a chart, graph, plot, or diagram means writing a Python script that draws it, running that script with `shell`, and sending the picture it made with `send_image`. @@ -141,12 +144,12 @@ Mermaid and other diagram code do not render here: a diagram written as text rea 3. Look at the PNG with `view_image`: labels readable, nothing overlapping, axes and title named. 4. Send it with `send_image`. To change it, `edit` the script, run it again, and send the new version. -Save the PNG where the user asked, or in the scratch directory when they named no place: sending it is how they get it. +Save the PNG in Downloads, or where the user asked, and send it. == Documents (.docx and .pdf) == `read` opens .pdf and .docx files directly and returns their text with line numbers; don't shell out to a script just to read one. A legacy .doc isn't supported: tell the user to save it as .docx. A PDF with no extractable text (a scan) comes back as an explicit error: treat it as unreadable, not empty. -To make one, run a short Python script with `shell`: `python-docx` (imported as `docx`) for .docx, `fpdf2` (imported as `fpdf`) for simple PDFs, `reportlab` for finer layout. Install the library non-interactively first (`python -m pip install --quiet `, `python3` outside Windows); if that fails, tell the user which package to install. Write the script in the scratch directory and delete it afterwards, write the document where the user asked (or the working directory with a sensible name), check it exists and is non-empty, report its full path, and send a PDF with `send_pdf`. +To make one, run a short Python script with `shell`: `python-docx` (imported as `docx`) for .docx, `fpdf2` (imported as `fpdf`) for simple PDFs, `reportlab` for finer layout. Install the library non-interactively first (`python -m pip install --quiet `, `python3` outside Windows); if that fails, tell the user which package to install. Write the script in the scratch directory and delete it afterwards, write the document to Downloads with a sensible name (or where the user asked), check it exists and is non-empty, report its full path, and send a PDF with `send_pdf`. == Describing a project == Never answer from a directory listing, file name, or repo name alone. Gather evidence from inside the folder the user named, by full path: list a couple of levels of the tree, read its README (try README.rst, README.txt, docs/README.md if the obvious name fails), read the manifest (pyproject.toml, package.json, Cargo.toml, go.mod, pom.xml), and if both are missing, read the entry point and the central source files. Then explain, naming the file each claim came from: what it is and the problem it solves, how it's built and run, how it's laid out, and what a new contributor needs to know (entry points, configuration, tests). If you find yourself writing "likely" or "it appears", go read more first. Skip the git status unless asked. diff --git a/flash/theme.py b/flash/theme.py index f5d8a1b..51b842c 100644 --- a/flash/theme.py +++ b/flash/theme.py @@ -329,6 +329,40 @@ def tool_file(path: str) -> bool: return True +def tool_browser( + path: str, *, run: str, url: str, title: str = "", note: str = "", +) -> bool: + """Hand whoever is drawing a picture of the agent's browser as it + stands: one frame of RUN, a page opened and acted on in turn, taken + after NOTE, the action that got it there. + + True when something took it: the web UI, which shows the browser + live beside the chat. False in the terminal, which has no picture. + """ + + sink = _sink() + if sink is None: + return False + sink("browser", json.dumps({ + "path": path, "run": run, "url": url, "title": title, "note": note, + }), "") + return True + + +def tool_document(path: str, comments: list[dict]) -> bool: + """Hand a document to whoever is drawing, with the agent's comments + on it: each a quote from the document and a note about it. + + True when something took it, as with `tool_file`. + """ + + sink = _sink() + if sink is None: + return False + sink("document", json.dumps({"path": path, "comments": comments}), "") + return True + + def tool_plan(steps: list[dict]) -> bool: """Hand the plan's checklist to whoever is drawing, as its steps. diff --git a/flash/tools.py b/flash/tools.py index f87eddb..a8bddf1 100644 --- a/flash/tools.py +++ b/flash/tools.py @@ -15,6 +15,7 @@ import threading import time import urllib.error +import urllib.parse import urllib.request from datetime import datetime from html.parser import HTMLParser @@ -27,7 +28,15 @@ from rich.text import Text from . import agent as subagents -from . import checkpoint, editor, extensions, learning, plan, skills +from . import ( + checkpoint, + editor, + extensions, + learning, + model3d, + plan, + skills, +) from .browser import ( ACTIONS, MAX_ELEMENTS, @@ -56,11 +65,14 @@ ELLIPSIS, ERROR, WARN, + capturing, console, glimmer, plural, remote_answer, + tool_browser, tool_diff, + tool_document, tool_file, tool_line, tool_result, @@ -120,9 +132,21 @@ To hand the user a finished web page, use the send_html tool with its path. It opens in their browser, or beside the chat in the web UI. Screenshot it first and send it once it looks right. -When you make an image, PDF, or web page for the user, send it with the - matching tool as soon as it is finished, without being asked: that is - how they see it. +To hand the user a Markdown or text document (a report, plan, README, + notes), use the send_document tool with its path. In the web UI it + opens beside the chat, where they can edit it and comment on it; their + comments reach you as a message that quotes each passage. To flag + unfinished work, a gap, or a question in it, pass comments, each + quoting the words it is about. +To make a 3D model (an object, a prop, a room, a layout), use the + make_3d_model tool: it builds the model from parts such as boxes, + cylinders, spheres, lathed profiles, extruded outlines, and blocky + text for any words, logos, or signs, saves a + .glb, and shows it in a 3D viewer. Use send_3d_model to show a .glb, + .stl, or .obj file that already exists. +When you make an image, PDF, web page, or document for the user, send it + with the matching tool as soon as it is finished, without being asked: + that is how they see it. To see how a web page actually renders, use the screenshot tool on the .html file you wrote or on a URL. It runs a headless browser and attaches the picture, so it is the only way to check a page you built; @@ -184,11 +208,16 @@ Your temporary scratch directory is: {SCRATCH_DIR} It will be deleted when the program exits. Use it for temporary files, but do not assume it will persist across runs. -Always use the scratch directory for temporary files, including dummy, - sample, and test files the user asks for, and write them by their full - path there. Never write them to the user's home directory, other - directories, or the current working directory unless the user names - that place. +Always use the scratch directory for temporary files, including test + scripts, and dummy, sample, and test files the user asks for, and write + them by their full path there. Never write them to the user's home + directory, other directories, or the current working directory unless + the user names that place. +Save a finished file the user asked for (a picture, chart, PDF, document, + page, 3D model, or a script for them to keep) in their Downloads folder + by its full path, unless they name another place or it is a change to + the project you are working in. Find that folder's path once with a + shell command before the first such write. """.strip() now = datetime.now() # noqa: DTZ005 @@ -2407,6 +2436,347 @@ def send_html(path: str, caption: str = "") -> str: ) +DOCUMENT_SUFFIXES = (".md", ".markdown", ".txt") +MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 +MAX_MODEL_BYTES = 50 * 1024 * 1024 +MAX_DOC_COMMENTS = 20 +MAX_QUOTE_CHARS = 300 +MAX_NOTE_CHARS = 1000 + +_MD_LINK = re.compile(r"!?\[([^\]]*)\]\([^)]*\)") +_MD_LINE_MARK = re.compile( + r"^[ \t]*(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)", re.M +) +_MD_INLINE_MARK = re.compile(r"|\*\*|__|~~|`|(?|.+-])") + + +def _plain(text: str) -> str: + """Text as the page shows it: Markdown's marks gone, spaces single. + + A comment quotes words as they read, and the model may copy them + from the file with their marks or without, so both are compared + this way.""" + + text = _MD_LINK.sub(r"\1", text) + text = _MD_LINE_MARK.sub("", text) + text = _MD_INLINE_MARK.sub("", text) + text = _MD_ESCAPE.sub(r"\1", text) + return " ".join(text.split()) + + +def _doc_comments( + raw: Any, text: str, +) -> tuple[list[dict], list[str]]: + """The comments the model left on a document, cleaned, and a line + for each one whose quote is not in the document. + + A quote that is not found still goes: the page shows it as a note + on the whole document.""" + + if isinstance(raw, str): + try: + raw = json.loads(raw) if raw.strip() else [] + except json.JSONDecodeError: + return [], ["comments was not a list; none were added"] + if isinstance(raw, dict): + raw = [raw] + if not isinstance(raw, list): + return [], ["comments was not a list; none were added"] + + plain_text = _plain(text) + flat_text = " ".join(text.split()) + comments: list[dict] = [] + missing: list[str] = [] + for item in raw[:MAX_DOC_COMMENTS]: + if not isinstance(item, dict): + continue + quote = str(item.get("quote") or "").strip()[:MAX_QUOTE_CHARS] + note = str( + item.get("note") or item.get("comment") or item.get("text") or "" + ).strip()[:MAX_NOTE_CHARS] + if not note: + continue + comments.append({"quote": quote, "note": note}) + if quote and " ".join(quote.split()) not in flat_text and ( + _plain(quote) not in plain_text + ): + missing.append( + f'comment {len(comments)} quotes "{quote[:60]}", which is ' + "not in the document word for word, so it shows without " + "a place" + ) + return comments, missing + + +def send_document( + path: str, caption: str = "", comments: Any = None, +) -> str: + """Put a Markdown or text document in front of the user, with any + comments the model left on its passages.""" + + tool_line(f"SendDocument({path})") + + doc = Path(path).expanduser() + problem = "" + size = 0 + if not doc.is_file(): + problem = f"no file at {doc}" + elif doc.suffix.lower() not in DOCUMENT_SUFFIXES: + problem = f"{doc.name} is not a .md or .txt file" + else: + try: + size = doc.stat().st_size + doc.read_bytes().decode("utf-8") + except OSError as exc: + problem = f"could not read {doc}: {exc}" + except UnicodeDecodeError: + problem = f"{doc.name} is not UTF-8 text" + else: + if size > MAX_DOCUMENT_BYTES: + problem = ( + f"{doc.name} is {size // 1024} KB; the limit is " + f"{MAX_DOCUMENT_BYTES // (1024 * 1024)} MB" + ) + + if problem: + result = f"Error: {problem}." + tool_result(result, style=ERROR) + return result + + kilobytes = max(1, round(size / 1024)) + note = caption.strip() + label = f"{doc.name} ({kilobytes} KB)" + (f": {note}" if note else "") + left, missing = _doc_comments( + comments, doc.read_text(encoding="utf-8") + ) + count = len(left) + if count: + label += f", {count} comment{'' if count == 1 else 's'}" + unplaced = f" To fix: {'; '.join(missing)}." if missing else "" + pinned = ( + f" Your {count} comment{'' if count == 1 else 's'} show" + f"{'s' if count == 1 else ''} on the passages quoted." + if count else "" + ) + + shown = tool_document(str(doc), left) if left else tool_file(str(doc)) + if shown: + tool_result(label) + return ( + f"Sent {doc.name} ({kilobytes} KB) to the user's screen, " + f"beside the chat.{pinned} They can edit it there, and " + "saving writes the file. Their comments come to you as a " + f"message quoting each passage.{unplaced}" + ) + + problem = _open_with_spinner(doc) + tool_result(label + (f" ({problem})" if problem else "")) + console.print( + Text(f"{' ' * RESULT_INDENT}{_display_path(doc)}", style=DIM) + ) + # The app it opens in has no place for them: they print under it. + for item in left: + where = f'"{item["quote"]}": ' if item["quote"] else "" + console.print( + Text(f"{' ' * RESULT_INDENT}{where}{item['note']}", style=DIM) + ) + + if problem: + return ( + f"Could not open {doc.name}: {problem}. Its path is on " + "screen; tell the user where the file is." + ) + return ( + f"Sent {doc.name} ({kilobytes} KB). It opened in the user's " + "default app for it, with its path on screen" + + (", and your comments printed under it." if left else ".") + ) + + +MODEL_PREVIEW_WIDTH = 900 +MODEL_PREVIEW_HEIGHT = 700 +MODEL_PREVIEW_WAIT_MS = 1500 +_model_count = 0 + + +def _model_preview(model_path: Path) -> str: + """Draw the model the way the web UI will, and attach the picture + for the model to judge. Returns a line for its tool result.""" + + global _model_count + + if not model_sees_images(OLLAMA_HOST, MODEL_NAME): + return ( + f"The active model ({MODEL_NAME}) has no vision, so there is " + "no picture of it; check the numbers above against what you " + "meant to build." + ) + + web = Path(__file__).parent / "web" / "three" + try: + page = model3d.preview_page( + model_path.read_bytes(), model_path.suffix.lower(), + (web / "viewer.js").read_text(encoding="utf-8"), + (web / "three.min.js").read_text(encoding="utf-8"), + ) + except OSError as exc: + return f"No picture of it: {exc}." + + _model_count += 1 + html = Path(SCRATCH_DIR) / f"model-{_model_count}.html" + out = Path(SCRATCH_DIR) / f"model-{_model_count}.png" + html.write_text(page, encoding="utf-8") + problems, why = capture( + html.resolve().as_uri(), out, + width=MODEL_PREVIEW_WIDTH, height=MODEL_PREVIEW_HEIGHT, + full_page=False, wait_ms=MODEL_PREVIEW_WAIT_MS, + ) + if why: + tool_result(f"No preview: {why}", style=WARN) + return f"No picture of it: {why}" + + data = out.read_bytes() + _pending_images.append(data) + note = ( + "A picture of it, as the user's viewer first shows it (from the " + "front right, above), is attached, so judge the shape from what " + "you can see there. If anything is wrong in it (a part hidden, " + "floating, or out of place), fix it now by calling the tool " + "again, before you write your reply." + ) + if problems: + note += " The viewer reported: " + "; ".join( + problems[:MAX_PAGE_PROBLEMS] + ) + return note + + +def _show_model(model_path: Path, label: str) -> str: + """Put a model in front of the user. Returns how it went.""" + + if tool_file(str(model_path)): + tool_result(label) + return ( + "It is on the user's screen beside the chat, in a 3D viewer " + "they can turn, zoom, and download it from." + ) + + problem = _open_with_spinner(model_path) + tool_result(label + (f" ({problem})" if problem else "")) + console.print( + Text(f"{' ' * RESULT_INDENT}{_display_path(model_path)}", style=DIM) + ) + if problem: + return ( + f"Could not open it: {problem}. Its path is on screen; tell " + "the user where the file is and that the web UI (flash --web) " + "shows 3D models." + ) + return ( + "It opened in the user's default 3D viewer, with its path on " + "screen." + ) + + +def make_3d_model( + path: str, parts: Any, title: str = "", caption: str = "", +) -> str: + """Build a 3D model out of simple parts, save it as a .glb file, + and show it to the user.""" + + model_path = Path(path).expanduser() + if model_path.suffix.lower() != ".glb": + model_path = model_path.with_name(model_path.name + ".glb") + tool_line(f"Make3DModel({model_path})") + + problem = "" + if model_path.is_dir(): + problem = f"{model_path} is a directory, not a file" + elif model_path.exists(): + try: + with open(model_path, "rb") as handle: + if not model3d.is_glb(handle.read(12)): + problem = ( + f"{model_path.name} already exists and is not a " + ".glb model, so it was left alone; pick another " + "path" + ) + except OSError as exc: + problem = f"could not read {model_path}: {exc}" + if problem: + result = f"Error: {problem}." + tool_result(result, style=ERROR) + return result + + try: + built = model3d.build_parts(parts) + data = model3d.to_glb(built, str(title or "").strip()) + except model3d.ModelError as exc: + result = f"Error: {exc}. Nothing was written." + tool_result(result, style=ERROR) + return result + + checkpoint.record(model_path) + try: + model_path.parent.mkdir(parents=True, exist_ok=True) + model_path.write_bytes(data) + except OSError as exc: + result = f"Error: could not write {model_path}: {exc}" + tool_result(result, style=ERROR) + return result + + kilobytes = max(1, round(len(data) / 1024)) + note = caption.strip() + count = len(built) + label = ( + f"{model_path.name} ({count} part{plural(count)}, {kilobytes} KB)" + + (f": {note}" if note else "") + ) + shown = _show_model(model_path, label) + return ( + f"Saved {model_path} ({kilobytes} KB).\n" + f"{model3d.describe(built)}\n{shown}\n{_model_preview(model_path)}" + "\nTo change it, call make_3d_model again with the whole list " + "of parts, changed, and the same path." + ) + + +def send_3d_model(path: str, caption: str = "") -> str: + """Put a 3D model file someone else made in front of the user.""" + + tool_line(f"Send3DModel({path})") + + model_path = Path(path).expanduser() + size = 0 + if not model_path.is_file(): + problem = f"no file at {model_path}" + else: + problem = model3d.check_model_file(model_path) or "" + if not problem: + size = model_path.stat().st_size + if size > MAX_MODEL_BYTES: + problem = ( + f"{model_path.name} is {size // (1024 * 1024)} MB; the " + f"limit is {MAX_MODEL_BYTES // (1024 * 1024)} MB" + ) + if problem: + result = f"Error: {problem}." + tool_result(result, style=ERROR) + return result + + kilobytes = max(1, round(size / 1024)) + note = caption.strip() + label = f"{model_path.name} ({kilobytes} KB)" + ( + f": {note}" if note else "" + ) + shown = _show_model(model_path, label) + return ( + f"Sent {model_path.name} ({kilobytes} KB). {shown}\n" + f"{_model_preview(model_path)}" + ) + + DEFAULT_SCREENSHOT_WIDTH = 1280 DEFAULT_SCREENSHOT_HEIGHT = 800 MIN_SCREENSHOT_SIDE = 200 @@ -2416,6 +2786,31 @@ def send_html(path: str, caption: str = "") -> str: MAX_PAGE_PROBLEMS = 5 _screenshot_count = 0 +# Each page opened, and each one-off screenshot, is a run of its own: the +# web UI shows one live browser per run, its frames in order. Random, not +# counted, so a chat that outlives a restart never mixes two runs up. +_browser_run = "" + + +def _new_browser_run() -> None: + global _browser_run + _browser_run = os.urandom(4).hex() + + +def _show_browser(path: Path, url: str, title: str, note: str) -> None: + """Show the user the frame just taken, where there is a screen for it.""" + + tool_browser(str(path), run=_browser_run, url=url, title=title, + note=note) + + +def _mouse_point(value: Any) -> Optional[tuple[float, float]]: + """A point the model gave for the mouse, as "x,y", or None.""" + + numbers = re.findall(r"-?\d+(?:\.\d+)?", str(value or "")) + if len(numbers) < 2: + return None + return float(numbers[0]), float(numbers[1]) def _clamp(value: Any, low: int, high: int, fallback: int) -> int: @@ -2440,6 +2835,7 @@ def screenshot( height: Any = DEFAULT_SCREENSHOT_HEIGHT, full_page: Any = False, wait_ms: Any = DEFAULT_SCREENSHOT_WAIT_MS, + mouse: Any = "", ) -> str: """Render a page in a headless browser and attach the picture.""" @@ -2456,6 +2852,9 @@ def screenshot( shape = f"{view_width}x{view_height}" if whole_page: shape += " full page" + pointer = _mouse_point(mouse) + if pointer is not None: + shape += f", mouse at {pointer[0]:g},{pointer[1]:g}" tool_line(f"Screenshot({target}, {shape})") url, why = resolve_target(target) @@ -2483,6 +2882,7 @@ def screenshot( height=view_height, full_page=whole_page, wait_ms=settle_ms, + mouse=pointer, ) if why: @@ -2495,6 +2895,8 @@ def screenshot( kilobytes = max(1, round(len(data) / 1024)) tool_result(f"{shape} ({kilobytes} KB) {out.name}") + _new_browser_run() + _show_browser(out, url, "", f"Screenshot at {shape}") if problems: for problem in problems[:MAX_PAGE_PROBLEMS]: @@ -2522,7 +2924,9 @@ def screenshot( return result -def _page_report(headline: str, *, full_page: bool = False) -> str: +def _page_report( + headline: str, *, full_page: bool = False, step: str = "", +) -> str: """Show the model the page it just acted on. Every open_page and interact call ends here, because an action the @@ -2539,28 +2943,33 @@ def _page_report(headline: str, *, full_page: bool = False) -> str: if url: lines.append(f"Page: {title or 'untitled'} - {url}") - if model_sees_images(OLLAMA_HOST, MODEL_NAME): + sees = model_sees_images(OLLAMA_HOST, MODEL_NAME) + # The picture is for the model when it has eyes, and for the user + # whenever there is a screen beside the chat to show the browser on. + if sees or capturing(): _screenshot_count += 1 out = Path(SCRATCH_DIR) / f"page-{_screenshot_count}.png" why = page_snapshot(out, full_page=bool(full_page)) + if not why: + _show_browser(out, url, title, step or headline.split("\n")[0]) - if why: - lines.append(f"No screenshot of the page: {why}") - tool_result(why, style=WARN) - else: - data = out.read_bytes() - _pending_images.append(data) - kilobytes = max(1, round(len(data) / 1024)) - tool_result(f"{out.name} ({kilobytes} KB)") - lines.append( - "A screenshot of the page as it stands is attached to this " - "tool result, so judge it from what you can see there." - ) - else: + if not sees: lines.append( f"The active model ({MODEL_NAME}) has no vision, so there is no " "screenshot. Work from the element list and from eval." ) + elif why: + lines.append(f"No screenshot of the page: {why}") + tool_result(why, style=WARN) + else: + data = out.read_bytes() + _pending_images.append(data) + kilobytes = max(1, round(len(data) / 1024)) + tool_result(f"{out.name} ({kilobytes} KB)") + lines.append( + "A screenshot of the page as it stands is attached to this " + "tool result, so judge it from what you can see there." + ) found, why = page_elements() if why: @@ -2628,10 +3037,17 @@ def open_page( tool_result(result, style=ERROR) return result + _new_browser_run() return _page_report( - f"Opened {url} at {shape}. The browser stays open, so use the " - "interact tool to click, type, or run JavaScript on this page, and " - "close it when you are done." + step=( + f"Opened {Path(urllib.parse.urlparse(url).path).name or url} " + f"at {shape}" + ), + headline=( + f"Opened {url} at {shape}. The browser stays open, so use the " + "interact tool to click, type, or run JavaScript on this page, " + "and close it when you are done." + ), ) @@ -3089,6 +3505,299 @@ def interact( }, }, }, + { + "type": "function", + "function": { + "name": "send_document", + "description": ( + "Show a Markdown or text document (.md, .markdown, .txt) " + "to the user: a report, plan, README, or notes you wrote. " + "In the web UI it opens beside the chat, rendered, where " + "they can edit it and comment on passages; their comments " + "reach you as a message quoting each one. Write the file " + "first; this only shows it. To flag something for them in " + "it (unfinished work, a gap to fill, an open question, an " + "assumption to check), add comments pinned to passages." + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the document.", + }, + "caption": { + "type": "string", + "description": ( + "Optional single line shown with it." + ), + }, + "comments": { + "type": "array", + "description": ( + "Optional notes for the user, each pinned to " + "a passage: what is unfinished, missing, or " + "needs their decision there." + ), + "items": { + "type": "object", + "properties": { + "quote": { + "type": "string", + "description": ( + "A few words from the document, " + "exactly as they read, marking " + "the passage." + ), + }, + "note": { + "type": "string", + "description": "What to tell them.", + }, + }, + "required": ["quote", "note"], + }, + }, + }, + "required": ["path"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "make_3d_model", + "description": ( + "Build a 3D model out of parts, save it as a .glb file, " + "and show it to the user in a 3D viewer they can turn and " + "zoom (beside the chat in the web UI). Use it for any " + "object, prop, scene, or layout the user wants to see in " + "3D. Y is up, units are metres, and the ground is y = 0, " + "so a part rests on it when its position's y is half its " + "height. Every shape is centred on its position. Build " + "the object from many parts, sized in proportion to the " + "real thing and coloured like it. Anything with words on " + "it (a logo, a sign, a title) gets a text part for them. " + "The result gives the model's overall size, " + "and a picture of it when you can see images: check both " + "and fix what is off. To revise, call again with the " + "whole changed list and the same path." + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": ( + "Full path to save it at, ending in .glb: in " + "the user's Downloads folder, as chair.glb " + "there, unless they named another place." + ), + }, + "parts": { + "type": "array", + "description": "The parts, each one shape.", + "items": { + "type": "object", + "properties": { + "shape": { + "type": "string", + "enum": list(model3d.SHAPES), + "description": ( + "box (size [x,y,z]); sphere " + "(radius); cylinder (radius, " + "height, or radius_top and " + "radius_bottom for a taper); cone " + "(radius, height, point up); " + "torus (radius to the tube's " + "centre, tube; lies flat like a " + "ring on a table); plane (size " + "[x,z], facing up); lathe (points " + "[[radius,y],...] from bottom to " + "top, spun round the Y axis: " + "vases, bottles, lamps, chess " + "pieces); extrude (points " + "[[x,z],...], an outline seen " + "from above, raised to height: " + "walls, floor plans, gears, " + "L-shapes); text (text, height " + "of a capital, depth: words in a " + "blocky pixel font, reading " + "along X and facing +Z, lines " + "split by \\n: logos, signs, " + "names; never stand a box in " + "for a letter); mesh (vertices " + "[[x,y,z],...] and faces, each a " + "list of vertex indices from 0)." + ), + }, + "text": { + "type": "string", + "description": ( + "For text: the words. A-Z, " + "0-9, spaces, and . , ! ? - : " + "' & /." + ), + }, + "depth": { + "type": "number", + "description": ( + "For text: how thick the " + "letters are; one block of the " + "font (height / 7) by default." + ), + }, + "name": { + "type": "string", + "description": ( + "What the part is, like " + "'left front leg'." + ), + }, + "size": { + "type": "array", + "items": {"type": "number"}, + }, + "radius": {"type": "number"}, + "radius_top": {"type": "number"}, + "radius_bottom": {"type": "number"}, + "height": {"type": "number"}, + "tube": {"type": "number"}, + "points": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "number"}, + }, + }, + "vertices": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "number"}, + }, + }, + "faces": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "integer"}, + }, + }, + "segments": { + "type": "integer", + "description": ( + "How smooth a round shape is, " + "3 to 128. 6 makes a hexagonal " + "prism of a cylinder." + ), + }, + "position": { + "type": "array", + "items": {"type": "number"}, + "description": ( + "[x, y, z] of the shape's centre." + ), + }, + "rotation": { + "type": "array", + "items": {"type": "number"}, + "description": ( + "[x, y, z] turns in degrees, " + "about the shape's centre. " + "[90, 0, 0] lays a cylinder on " + "its side along Z; [0, 0, 90] " + "along X." + ), + }, + "scale": { + "type": "array", + "items": {"type": "number"}, + "description": ( + "[x, y, z] stretch, such as a " + "sphere made an egg." + ), + }, + "color": { + "type": "string", + "description": ( + "A hex code like #c0392b, or a " + "common name." + ), + }, + "metalness": { + "type": "number", + "description": "0 (default) to 1.", + }, + "roughness": { + "type": "number", + "description": ( + "0 (mirror) to 1 (chalk); " + "default 0.6." + ), + }, + "opacity": { + "type": "number", + "description": ( + "1 (default) to 0; below 1 for " + "glass or water." + ), + }, + "emissive": { + "type": "string", + "description": ( + "A colour it glows, for lamps " + "and screens." + ), + }, + }, + "required": ["shape"], + }, + }, + "title": { + "type": "string", + "description": "Optional name for the model.", + }, + "caption": { + "type": "string", + "description": ( + "Optional single line shown with it." + ), + }, + }, + "required": ["path", "parts"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "send_3d_model", + "description": ( + "Show a 3D model file that already exists (.glb, .stl, or " + ".obj), such as one a script made or the user has, in a " + "3D viewer they can turn and zoom (beside the chat in the " + "web UI). For a model you build yourself, use " + "make_3d_model, which shows it too." + ), + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the .glb, .stl, or .obj.", + }, + "caption": { + "type": "string", + "description": ( + "Optional single line shown with it." + ), + }, + }, + "required": ["path"], + }, + }, + }, { "type": "function", "function": { @@ -3180,6 +3889,15 @@ def interact( "minimum": 0, "maximum": MAX_SCREENSHOT_WAIT_MS, }, + "mouse": { + "type": "string", + "description": ( + "Optional point to rest the mouse on before " + "the picture, as \"x,y\" in the viewport's " + "pixels, to see a hover state. The pointer " + "shows in the picture as an orange arrow." + ), + }, }, "required": ["target"], }, @@ -3252,12 +3970,15 @@ def interact( "Do one thing to the page open_page opened, then look at " "the result: click a button, fill a field, press a key, " "choose an option, scroll, wait for something to appear, " - "or run JavaScript against the live page. The page keeps " - "its state between calls, so work through a flow one call " - "at a time. Every call reports where the page is now, its " - "numbered elements, and the errors it threw, with a " - "screenshot attached, so this is how you debug what a page " - "actually does rather than what its source says." + "or run JavaScript against the live page. The mouse can " + "also move, click, and drag at points in the screenshot, " + "for what no selector names, such as a canvas or a map; " + "the pointer shows in each screenshot as an orange arrow. " + "The page keeps its state between calls, so work through a " + "flow one call at a time. Every call reports where the page " + "is now, its numbered elements, and the errors it threw, " + "with a screenshot attached, so this is how you debug what " + "a page actually does rather than what its source says." ), "parameters": { "type": "object", @@ -3265,9 +3986,13 @@ def interact( "action": { "type": "string", "description": ( - "What to do: 'click', 'fill' (type value into " - "a field), 'press' (send a key such as Enter or " - "Tab), 'hover', 'select' (choose value in a " + "What to do: 'click' (an element, or with no " + "selector the point \"x,y\" in value), 'move' " + "(the mouse onto an element or to a point, and " + "leave it there), 'drag' (value \"x1,y1 " + "x2,y2\"), 'fill' (type value into a field), " + "'press' (send a key such as Enter or Tab), " + "'hover', 'select' (choose value in a " "dropdown), 'scroll', 'wait', 'eval' (run the " "JavaScript in value and return its result), " "'back', 'reload', or 'close' (shut the " @@ -3292,8 +4017,10 @@ def interact( "description": ( "The text to type for fill, the key for press, " "the option for select, the JavaScript for " - "eval, or 'top', 'bottom', or a number of " - "pixels for scroll." + "eval, 'top', 'bottom', or a number of pixels " + "for scroll, or a point for click, move, and " + "drag, in the screenshot's pixels from its " + "top-left corner." ), }, "wait_ms": { @@ -3693,6 +4420,9 @@ def interact( "send_image": send_image, "send_pdf": send_pdf, "send_html": send_html, + "send_document": send_document, + "make_3d_model": make_3d_model, + "send_3d_model": send_3d_model, "screenshot": screenshot, "open_page": open_page, "interact": interact, diff --git a/flash/version.py b/flash/version.py index 942c51a..a48e612 100644 --- a/flash/version.py +++ b/flash/version.py @@ -1,4 +1,4 @@ -__version__ = "0.5.11" +__version__ = "0.5.12" REPO = "Natuworkguy/Flash" REPO_URL = f"https://github.com/{REPO}" diff --git a/flash/voice.py b/flash/voice.py index 7842ff5..4bb678b 100644 --- a/flash/voice.py +++ b/flash/voice.py @@ -22,6 +22,7 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +from typing import Optional from urllib.error import URLError from urllib.request import Request, urlopen @@ -258,8 +259,23 @@ def web_missing() -> list[str]: return [p for p in missing_packages() if p != "sounddevice"] -def _download(url: str, out: Path, label: str, on_progress: Progress) -> str: - """Stream `url` to `out`, reporting percent complete as it goes.""" +# What a download returns when it was called off: not a failure to +# report, and nothing of it is left on disk. +CANCELLED = "cancelled" + + +class _Stopped(Exception): + """The download was called off between chunks.""" + + +def _download( + url: str, out: Path, label: str, on_progress: Progress, + stop: Optional[threading.Event] = None, +) -> str: + """Stream `url` to `out`, reporting percent complete as it goes. + + Setting `stop` calls it off at the next chunk, a moment at most, and + it returns CANCELLED with the part it had deleted.""" out.parent.mkdir(parents=True, exist_ok=True) part = out.with_suffix(out.suffix + ".part") @@ -274,6 +290,8 @@ def _download(url: str, out: Path, label: str, on_progress: Progress) -> str: with part.open("wb") as handle: while True: + if stop is not None and stop.is_set(): + raise _Stopped chunk = response.read(DOWNLOAD_CHUNK) if not chunk: break @@ -284,6 +302,9 @@ def _download(url: str, out: Path, label: str, on_progress: Progress) -> str: on_progress(label, percent) part.replace(out) + except _Stopped: + part.unlink(missing_ok=True) + return CANCELLED except (URLError, OSError, ValueError) as exc: part.unlink(missing_ok=True) return f"could not download {label}: {exc}" @@ -310,7 +331,9 @@ def _unpack(archive: Path, into: Path) -> str: return "" -def ensure_models(on_progress: Progress) -> str: +def ensure_models( + on_progress: Progress, stop: Optional[threading.Event] = None, +) -> str: """Download whatever voice mode is missing. Returns "" when ready. Both models are large enough that the download is worth showing, so @@ -321,11 +344,13 @@ def ensure_models(on_progress: Progress) -> str: if models_present(): return "" - return (download_listening(vosk_model(), on_progress) - or download_voice(piper_voice(), on_progress)) + return (download_listening(vosk_model(), on_progress, stop) + or download_voice(piper_voice(), on_progress, stop)) -def download_listening(name: str, on_progress: Progress) -> str: +def download_listening( + name: str, on_progress: Progress, stop: Optional[threading.Event] = None, +) -> str: """Fetch and unpack the Vosk model NAME. Returns "" once it is in.""" if listening_installed(name): @@ -335,6 +360,7 @@ def download_listening(name: str, on_progress: Progress) -> str: archive = MODELS_DIR / f"{name}.zip" why = _download( f"{VOSK_BASE}/{name}.zip", archive, "listening model", on_progress, + stop, ) if why: return why @@ -351,7 +377,9 @@ def download_listening(name: str, on_progress: Progress) -> str: return "" -def download_voice(name: str, on_progress: Progress) -> str: +def download_voice( + name: str, on_progress: Progress, stop: Optional[threading.Event] = None, +) -> str: """Fetch the Piper voice NAME, network and settings. Returns "" once it is in.""" @@ -368,8 +396,13 @@ def download_voice(name: str, on_progress: Progress) -> str: onnx, config = piper_paths(name) MODELS_DIR.mkdir(parents=True, exist_ok=True) - return (_download(onnx_url, onnx, "voice", on_progress) - or _download(config_url, config, "voice settings", on_progress)) + why = (_download(onnx_url, onnx, "voice", on_progress, stop) + or _download(config_url, config, "voice settings", on_progress, + stop)) + # Called off between its two files: no voice without its settings. + if why == CANCELLED: + onnx.unlink(missing_ok=True) + return why def remove_listening(name: str) -> None: diff --git a/flash/web.py b/flash/web.py index 7aba5f6..fc29494 100644 --- a/flash/web.py +++ b/flash/web.py @@ -107,6 +107,13 @@ if path.is_file() and path.suffix in KATEX_TYPES }) +# three.js and Flash's viewer on it, which draw the 3D models the agent +# makes: shipped with Flash too, and loaded only once a model is opened. +STATIC.update({ + path.relative_to(WEB_DIR).as_posix(): KATEX_TYPES[path.suffix] + for path in sorted((WEB_DIR / "three").glob("*.js")) +}) + # How often an idle event stream says it is still there. Proxies and # some browsers drop a stream that has been silent for a minute. PING_SECONDS = 15 @@ -304,7 +311,7 @@ class Ask: # The rest (tokens, status) only matter to a page that is watching. KEPT = { "user", "assistant", "tool", "result", "diff", "ask", "answered", - "error", "stats", "note", "thought", "file", "plan", + "error", "stats", "note", "thought", "file", "plan", "browser", } @@ -442,6 +449,13 @@ def sign_out_others(self, keep: str) -> list[str]: self.token = secrets.token_urlsafe(24) return keys + def new_token(self) -> str: + """End the link and make another. Signed-in browsers stay.""" + + with self._lock: + self.token = secrets.token_urlsafe(24) + return self.token + def listing(self, current: str, watching: set) -> list[dict]: """The signed-in browsers, as the page shows them, newest first.""" @@ -630,10 +644,16 @@ class Session: def __init__(self, hub: Optional[Hub] = None) -> None: self.hub = hub or Hub() self.lan = False + # Documents the user edited in the page since each chat's last + # turn: chat id -> the paths, told to the model with its next + # message so it reads them again rather than writing over them. + self.edited: dict[str, list[str]] = {} # The voice models are being downloaded for the page: the first # use's pair, or one model picked in Settings, (kind, name). self.voice_setup = False self.voice_job: Optional[tuple[str, str]] = None + # Set to call off whichever of those is running. + self.voice_stop = threading.Event() # The link's token and the browsers signed in with it. A server # restarted after an update takes over the old one's (Server). self.access = Access() @@ -647,7 +667,7 @@ def __init__(self, hub: Optional[Hub] = None) -> None: # Starts this same `flash --web` again, as whatever version is # installed now. Only a server that owns its process can: one # beside a terminal session would take the session down with it. - self.restart: Optional[Callable[[], None]] = None + self.restart: Optional[Callable[..., None]] = None # Opens the server again listening on the network, or not. Set # by whatever runs the server (see _attach). self.switch_lan: Optional[Callable[[bool], None]] = None @@ -755,6 +775,7 @@ def set_up_voice(self) -> None: if self.voice_setup or self.voice_job: return self.voice_setup = True + self.voice_stop.clear() def run() -> None: said: dict[str, int] = {} @@ -769,13 +790,15 @@ def progress(label: str, percent: int) -> None: why = "" try: - why = voice.ensure_models(progress) + why = voice.ensure_models(progress, self.voice_stop) finally: with self._lock: self.voice_setup = False - self.hub.publish( - {"type": "voice-setup", "done": True, "error": why} - ) + cancelled = why == voice.CANCELLED + self.hub.publish({ + "type": "voice-setup", "done": True, + "error": "" if cancelled else why, "cancelled": cancelled, + }) threading.Thread(target=run, daemon=True).start() @@ -790,6 +813,7 @@ def fetch_voice_model(self, kind: str, name: str) -> None: if self.voice_setup or self.voice_job: raise ValueError("A voice model is already downloading.") self.voice_job = (kind, name) + self.voice_stop.clear() def run() -> None: said: list[int] = [-1] @@ -806,7 +830,7 @@ def progress(label: str, percent: int) -> None: try: fetch = (voice.download_listening if kind == "listening" else voice.download_voice) - why = fetch(name, progress) + why = fetch(name, progress, self.voice_stop) if not why: ai.set_config_var(VOICE_SETTINGS[kind], name) except Exception as exc: # noqa: BLE001 @@ -814,13 +838,25 @@ def progress(label: str, percent: int) -> None: finally: with self._lock: self.voice_job = None + cancelled = why == voice.CANCELLED self.hub.publish({ "type": "voice-model", "kind": kind, "name": name, - "done": True, "error": why, + "done": True, "error": "" if cancelled else why, + "cancelled": cancelled, }) threading.Thread(target=run, daemon=True).start() + def cancel_voice_download(self) -> bool: + """Call off the voice download running, if one is. True when one + was: it stops at its next chunk and says so to every page.""" + + with self._lock: + running = self.voice_setup or self.voice_job is not None + if running: + self.voice_stop.set() + return running + # Sub-agents ---------------------------------------------------- def adopt(self, chat: Chat, agent_ids: set) -> None: @@ -1245,9 +1281,26 @@ def sink(kind: str, text: str, style: str) -> None: session.emit(chat, {"type": "diff", "text": text}) elif kind == "plan": session.emit(chat, {"type": "plan", "steps": json.loads(text)}) - elif kind == "file": + elif kind == "browser": + # A frame of the agent's browser: kept like any file shown, + # so the chat still has it after a restart. + shown = json.loads(text) + try: + kept = workspace.keep_file(shown["path"]) + except (workspace.WorkspaceError, OSError): + return + session.emit(chat, { + "type": "browser", **kept, "run": str(shown["run"]), + "url": str(shown.get("url") or ""), + "title": str(shown.get("title") or ""), + "note": str(shown.get("note") or ""), + }) + elif kind in ("file", "document"): + shown = json.loads(text) if kind == "document" else {"path": text} try: - kept = workspace.keep_file(text) + kept = workspace.keep_file(shown["path"]) + if shown.get("comments"): + kept["comments"] = shown["comments"] session.emit(chat, {"type": "file", **kept}) except (workspace.WorkspaceError, OSError) as exc: session.emit(chat, { @@ -1325,6 +1378,17 @@ def project_prompt(found: "workspace.Project") -> str: return "\n".join(lines) +def edited_note(paths: list[str]) -> str: + """What the model is told about documents the user edited by hand.""" + + names = ", ".join(paths) + return ( + f"[The user edited {names} in the side panel and saved it. Read " + "it again before you change it: their version is the one that " + "counts.]" + ) + + def attachments(files: Optional[list]) -> list[dict]: """The files a message carries, as the page shows them: those that are really there, and no more than MAX_ATTACHMENTS.""" @@ -1392,6 +1456,11 @@ def run_turn( # its own sub-agents and never another chat's. owned = session.owned(chat.id) news, delivered = subagents.notices(owned) if owned else ("", []) + edited = session.edited.pop(chat.id, []) + if edited: + news = "\n\n".join( + part for part in (news, edited_note(edited)) if part + ) said, images = outgoing(ai, text, files) if images and not model_sees_images(ai.Config.host, ai.Config.model): session.emit(chat, {"type": "note", "text": ( @@ -1414,6 +1483,7 @@ def run_turn( convo = [system, *chat.messages] keep_from = len(convo) tool_count = 0 + nudged = 0 reply = Streamed() for _round in range(ai.Config.max_tool_rounds): @@ -1421,7 +1491,18 @@ def run_turn( tokens += reply.tokens generating += reply.seconds - if reply.stopped or not reply.calls: + if reply.stopped: + break + if not reply.calls: + # It said what it would do next and stopped short of + # doing it: keep what it said, and tell it to go on. + if (offered and nudged < ai.MAX_PROMISE_NUDGES + and ai.unkept_promise(reply.content)): + nudged += 1 + _finish_reply(session, chat, reply) + convo.append(ai._message("assistant", reply.content)) + convo.append(ai.promise_nudge()) + continue break _finish_reply(session, chat, reply) @@ -1918,6 +1999,23 @@ def command(session: Session, body: dict, browser: str = "") -> dict: threading.Timer(RESTART_DELAY, session.restart).start() return session.updates.snapshot() + if name == "server-restart": + # From the page's shortcut: Flash started again, with a new link. + # The page is handed the new token to come back in with. + if session.restart is None: + raise ValueError( + "This Flash runs beside a terminal session. Quit it there " + "and start it again." + ) + if any(c.busy or c.queued for c in session.chats.values()): + raise ValueError("Wait for the reply to finish first.") + token = session.access.new_token() + threading.Timer( + RESTART_DELAY, session.restart, + ("Restarting, as asked from the page. The new link follows.",), + ).start() + return {"token": token} + if name == "skills": return {"skills": [ { @@ -2013,6 +2111,9 @@ def command(session: Session, body: dict, browser: str = "") -> dict: if name == "voice-models": return voice_models(session) + if name == "voice-cancel": + return {"cancelling": session.cancel_voice_download()} + if name in ("voice-model", "voice-model-remove"): kind = str(body.get("kind") or "") if kind not in VOICE_KINDS: @@ -2138,6 +2239,16 @@ def command(session: Session, body: dict, browser: str = "") -> dict: if name == "dirs": return {"dirs": workspace.folder_suggestions(arg)} + if name == "document-save": + saved = workspace.save_document(arg, str(body.get("text") or "")) + # Told to the model with the chat's next message. + where = saved["path"] or arg + if chat_id in session.chats: + listed = session.edited.setdefault(chat_id, []) + if where not in listed: + listed.append(where) + return saved + if name == "undo": message = checkpoint.undo() if chat_id in session.chats: @@ -2567,6 +2678,12 @@ def _events(self) -> None: class Server(ThreadingHTTPServer): daemon_threads = True + # SO_REUSEADDR only lets a restart take back a port its old + # connections still hold, on Linux and macOS. On Windows it lets a + # second server bind a port another is listening on, and the two + # then split its connections; and Windows lets a port be reused + # without it anyway. + allow_reuse_address = os.name != "nt" def __init__( self, @@ -2575,8 +2692,10 @@ def __init__( lan: bool = False, ): # Set before binding: a port already in use makes the base class - # call server_close() from inside its own __init__. + # call server_close() from inside its own __init__, before any of + # the rest is set up. self.closing = threading.Event() + self.keep_session = False super().__init__((LAN_HOST if lan else HOST, port), Handler) # Cookies ignore the port, so two servers on one machine each # need a name of their own. @@ -2596,7 +2715,6 @@ def __init__( self.swapping = False self.swapped = threading.Event() self.replacement: Optional[Server] = None - self.keep_session = False @property def port(self) -> int: @@ -2637,9 +2755,14 @@ def network_url(self) -> Optional[str]: def server_close(self) -> None: self.closing.set() - self.session.hub.close() - if not self.keep_session: - self.session.close() + # No session yet when the port could not be had: there is nothing + # to close but the socket, and the OSError that says why has to + # get out. + session = getattr(self, "session", None) + if session is not None: + session.hub.close() + if not self.keep_session: + session.close() super().server_close() @@ -2743,7 +2866,9 @@ def _listen( RESTART_DELAY = 0.4 -def _restart(server: "Server") -> None: +def _restart( + server: "Server", why: str = "Restarting to finish the update.", +) -> None: """Start this `flash --web` again as the version now installed. exec replaces the process in place: the same port, the same @@ -2759,7 +2884,7 @@ def _restart(server: "Server") -> None: args.append("--lan") if "--no-open" not in args: args.append("--no-open") - console.print(Text("Restarting to finish the update.", style=DIM)) + console.print(Text(why, style=DIM)) os.execv( # nosec B606 -- this same interpreter, running Flash again sys.executable, [sys.executable, "-m", "flash", *args] ) @@ -2814,7 +2939,7 @@ def _attach(server: "Server", standalone: bool) -> None: # Windows cannot replace a running Flash at all: its update # finishes after this one quits, so there is nothing to # restart. - session.restart = lambda: _restart(server) + session.restart = lambda *why: _restart(server, *why) session.updates.can_restart = True else: _background = server diff --git a/flash/web/index.html b/flash/web/index.html index a7250b5..9ac96e2 100644 --- a/flash/web/index.html +++ b/flash/web/index.html @@ -317,6 +317,23 @@ padding: 10px 16px; border-radius: 20px 20px 6px 20px; white-space: pre-wrap; word-wrap: break-word; } +.sent-comments { width: min(460px, 88%); border: 1px solid var(--border); border-radius: 18px 18px 6px 18px; background: var(--surface); overflow: hidden; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } +.sent-comments .head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); text-align: left; color: var(--text); } +.sent-comments button.head { transition: background 0.12s; } +.sent-comments button.head:hover { background: var(--hover); } +.sent-comments .badge { width: 32px; height: 32px; flex: none; border-radius: 9px; display: grid; place-items: center; background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); } +.sent-comments .badge svg { width: 16px; height: 16px; } +.sent-comments .words { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; } +.sent-comments .name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sent-comments .meta { font-size: 12.5px; color: var(--text-3); } +.sent-comments .chev svg { width: 16px; height: 16px; color: var(--text-3); } +.sent-comments .items { list-style: none; margin: 0; padding: 4px 14px; } +.sent-comments .items li { padding: 10px 0; } +.sent-comments .items li + li { border-top: 1px solid var(--border); } +.sent-comments .where { display: block; margin-bottom: 4px; font: 600 11px/1.3 var(--mono); color: var(--text-3); } +.sent-comments .where:empty { display: none; } +.sent-comments q { display: block; padding-left: 9px; border-left: 3px solid var(--warn); font-size: 13px; line-height: 1.45; color: var(--text-3); quotes: none; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; } +.sent-comments p { margin: 6px 0 0; font-size: 14.5px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; } .assistant { font-size: 16px; line-height: 1.72; color: var(--text); word-wrap: break-word; } .assistant > :first-child { margin-top: 0; } .assistant > :last-child { margin-bottom: 0; } @@ -579,7 +596,10 @@ .user-files .doc .name { font-size: 13.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-files .doc .meta { font-size: 12px; color: var(--text-3); } button.doc:hover { border-color: var(--border-strong); } -#model-btn { margin-left: auto; border-color: transparent; max-width: 55%; } +#model-btn { margin-left: auto; border-color: transparent; max-width: 55%; min-width: 0; flex: 0 1 auto; overflow: hidden; } +/* Short of room, the model's name gives way; nothing else in the row + does, so the send button stays inside the box. */ +.composer-row > :not(#model-btn):not(#context) { flex-shrink: 0; } #model-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #model-btn svg { width: 14px; height: 14px; margin-right: -2px; } #context { font-size: 12px; color: var(--text-3); padding: 0 4px; } @@ -593,9 +613,6 @@ #send:active { transform: scale(0.94); } #send:disabled { opacity: 0.35; cursor: default; } #send.stop { background: var(--invert-bg); color: var(--invert-text); } -.shock { position: fixed; z-index: 60; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 11px; border: 2px solid var(--accent); pointer-events: none; animation: shock 0.6s cubic-bezier(0.2, 0, 0, 1) forwards; } -.shock.second { animation-delay: 0.08s; opacity: 0; border-width: 1px; } -@keyframes shock { 0% { opacity: 0.9; transform: scale(0.8); } 100% { opacity: 0; transform: scale(2.8); border-radius: 22px; } } #send.stop svg { width: 13px; height: 13px; } .fine { text-align: center; font-size: 12px; color: var(--text-3); margin-top: 10px; } body.empty .fine { display: none; } @@ -865,7 +882,15 @@ .browser-empty { padding: 18px 16px; color: var(--text-3); font-size: 14px; } .btn.danger { color: var(--del); border-color: color-mix(in srgb, var(--del) 40%, transparent); white-space: nowrap; } .btn.danger:hover { background: color-mix(in srgb, var(--del) 10%, transparent); } -.btn.danger.arm { background: var(--del); color: #fff; border-color: transparent; } +.btn.danger.solid { background: var(--del); color: #fff; border-color: transparent; } +.btn.danger.solid:hover { background: color-mix(in srgb, var(--del) 88%, #000); } +/* Asking before something that cannot be undone: a small card by the + button that asked, until it is answered. */ +.confirm-pop { position: fixed; z-index: 80; width: min(300px, calc(100vw - 24px)); padding: 14px 14px 12px; border-radius: 14px; border: 1px solid var(--border-strong); background: var(--surface); box-shadow: var(--shadow); animation: pop 0.14s var(--ease); transform-origin: var(--from, top right); } +.confirm-pop b { display: block; font-size: 14.5px; font-weight: 600; color: var(--text); margin-bottom: 4px; } +.confirm-pop p { margin: 0; font-size: 13.5px; line-height: 1.45; color: var(--text-2); } +.confirm-pop .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } +.confirm-pop .btn { padding: 6px 13px; font-size: 13px; } .setting-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; max-width: 620px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); text-align: left; width: 100%; } .setting-row .what b { display: block; font-size: 14.5px; font-weight: 500; color: var(--text); margin-bottom: 3px; } .setting-row .what span { font-size: 13px; color: var(--text-3); line-height: 1.45; } @@ -1051,19 +1076,54 @@ .attachment .meta { font-size: 12.5px; color: var(--text-3); } .attachment .chev svg { width: 16px; height: 16px; color: var(--text-3); } +/* The agent's browser: a window onto its newest step in the chat, and + the whole run, step by step, in the panel. */ +.browser-card { display: flex; flex-direction: column; width: min(440px, 100%); padding: 0; border-radius: 14px; border: 1px solid var(--border); background: var(--surface); overflow: hidden; text-align: left; transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease); animation: rise 0.28s var(--ease) both; } +.browser-card:hover { border-color: var(--border-strong); } +.browser-card.open { border-color: var(--accent); } +.browser-card .shot { display: block; aspect-ratio: 16 / 9; background: var(--code-bg); border-bottom: 1px solid var(--border); overflow: hidden; } +.browser-card .shot img { width: 100%; height: 100%; object-fit: cover; object-position: top left; display: block; } +.browser-card .bar { display: flex; align-items: center; gap: 10px; padding: 9px 12px; } +.browser-card .globe svg { width: 16px; height: 16px; color: var(--accent); display: block; } +.browser-card .info { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; } +.browser-card .name { font-weight: 500; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.browser-card .meta { font-size: 12.5px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.browser-card .chev svg { width: 16px; height: 16px; color: var(--text-3); display: block; } +.browser-view { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--code-bg); } +.browser-bar { display: flex; align-items: center; gap: 4px; padding: 8px 10px; border-bottom: 1px solid var(--border); background: var(--surface); } +.browser-bar .icon-btn { width: 30px; height: 30px; } +.browser-bar [data-prev] svg { transform: scaleX(-1); } +.browser-bar .icon-btn:disabled { opacity: 0.35; cursor: default; } +.browser-bar .url { flex: 1; min-width: 0; margin-left: 4px; padding: 6px 12px; border-radius: 999px; background: var(--bg); border: 1px solid var(--border); font: 12.5px var(--mono); color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.browser-bar .live { display: inline-flex; align-items: center; gap: 6px; margin-left: 6px; font-size: 12px; font-weight: 600; color: var(--accent); } +.browser-bar .live[hidden] { display: none; } +.browser-bar .live::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: pulse-dot 1.4s var(--ease) infinite; } +@keyframes pulse-dot { 50% { opacity: 0.35; } } +.browser-shot { flex: 1; min-height: 0; overflow: auto; padding: 14px; } +.browser-shot img { display: block; width: 100%; height: auto; border-radius: 8px; box-shadow: var(--shadow); background: #fff; } +.browser-step { padding: 9px 14px; border-top: 1px solid var(--border); background: var(--surface); font-size: 13px; color: var(--text-2); } +.browser-step:empty { display: none; } + #viewer { flex: none; width: 0; overflow: hidden; position: relative; border-left: 1px solid transparent; transition: width 0.46s var(--apple), border-color 0.46s var(--apple); } -#viewer.open { width: min(46vw, 780px); border-left-color: var(--border); } +#viewer.open { width: var(--viewer-w, min(46vw, 780px)); border-left-color: var(--border); } #viewer .pane { - position: absolute; top: 0; right: 0; bottom: 0; width: min(46vw, 780px); + position: absolute; top: 0; right: 0; bottom: 0; width: var(--viewer-w, min(46vw, 780px)); display: flex; flex-direction: column; background: var(--bg); transform: translateX(48px); opacity: 0; transition: transform 0.46s var(--apple), opacity 0.3s var(--ease); } #viewer.open .pane { transform: none; opacity: 1; } +/* The panel's left edge drags to make it wider or narrower. */ +.viewer-resize { position: absolute; top: 0; bottom: 0; left: 0; z-index: 5; width: 10px; cursor: col-resize; touch-action: none; outline: 0; } +.viewer-resize::after { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: var(--accent); opacity: 0; transition: opacity 0.15s var(--ease); } +.viewer-resize:hover::after, .viewer-resize:focus-visible::after, #viewer.resizing .viewer-resize::after { opacity: 1; transition-delay: 0.08s; } +#viewer.resizing, #viewer.resizing .pane { transition: none; } +body.resizing-viewer { cursor: col-resize; user-select: none; } +body.resizing-viewer iframe { pointer-events: none; } .viewer-bar { height: 56px; flex: none; display: flex; align-items: center; gap: 10px; padding: 0 10px 0 16px; border-bottom: 1px solid var(--border); @@ -1074,12 +1134,111 @@ #viewer-title { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #viewer-size { font-size: 12px; color: var(--text-3); } .viewer-bar a.icon-btn { text-decoration: none; } +.viewer-editing { display: flex; gap: 6px; margin-right: 6px; flex: none; } +.viewer-editing[hidden] { display: none; } #viewer-body { flex: 1; min-height: 0; position: relative; background: var(--code-bg); } #viewer-body iframe { width: 100%; height: 100%; border: 0; display: block; background: var(--code-bg); } #viewer-body iframe.page { background: #fff; } #viewer-body .image { position: absolute; inset: 0; overflow: auto; display: grid; place-items: center; padding: 20px; cursor: zoom-in; } #viewer-body .image img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px; box-shadow: var(--shadow); animation: rise-far 0.45s var(--apple) both; } #viewer-body .image.actual { place-items: start; cursor: zoom-out; } +/* A 3D model: the canvas fills the panel; a few controls float on it. */ +.model-view { position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 35%, var(--surface), var(--code-bg) 75%); } +.model-view .stage { position: absolute; inset: 0; cursor: grab; } +.model-view .stage:active { cursor: grabbing; } +.model-view .model-note { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; color: var(--text-3); font-size: 13.5px; pointer-events: none; } +.model-view .model-tools { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); display: flex; align-items: center; gap: 4px; padding: 4px; border-radius: 12px; background: color-mix(in srgb, var(--surface) 88%, transparent); border: 1px solid var(--border); box-shadow: var(--shadow); backdrop-filter: blur(8px); animation: rise-far 0.45s var(--apple) both; } +.model-view .model-tools[hidden] { display: none; } +.model-view .model-tools button { padding: 5px 10px; border-radius: 8px; font-size: 12.5px; color: var(--text-2); white-space: nowrap; } +.model-view .model-tools button:hover { background: var(--hover); color: var(--text); } +.model-view .model-tools button[aria-pressed="true"] { background: var(--hover); color: var(--text); } +.model-view .model-tools .hint { padding: 0 8px; font-size: 12px; color: var(--text-3); white-space: nowrap; } +@media (max-width: 560px) { .model-view .model-tools .hint { display: none; } } + +/* A document, set like a page: a readable column, generous margins. */ +#viewer-body .doc-scroll { position: absolute; inset: 0; overflow: auto; background: var(--bg); display: flex; flex-direction: column; } +#viewer-body:has(.doc-foot:not([hidden])) .doc-scroll { bottom: var(--foot, 220px); } +.doc { width: 100%; max-width: 720px; margin: 0 auto; padding: 36px 40px 72px; font-size: 16px; line-height: 1.75; color: var(--text); animation: rise 0.3s var(--ease) both; } +.doc.assistant h1 { font-size: 1.85em; letter-spacing: -0.02em; margin: 0 0 0.6em; line-height: 1.2; } +.doc.assistant h2 { font-size: 1.35em; margin: 1.8em 0 0.6em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); } +.doc.assistant h3 { font-size: 1.12em; margin: 1.5em 0 0.5em; } +.doc.assistant > :first-child { margin-top: 0; } +.doc.assistant table { margin: 0 0 1em; } +.doc.assistant img { max-width: 100%; } +.doc-plain { white-space: pre-wrap; font: 14px/1.65 var(--mono); } +.doc-error { color: var(--del); } +::highlight(doc-comment) { background-color: color-mix(in srgb, var(--warn) 30%, transparent); } +/* Flash's comments: its own colour, and a line under, so they read as + notes from it and not the user's. */ +::highlight(doc-note) { background-color: color-mix(in srgb, var(--accent) 16%, transparent); text-decoration: underline 2px color-mix(in srgb, var(--accent) 75%, transparent); text-underline-offset: 4px; } +.note-pop .who { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; font-size: 12px; font-weight: 600; color: var(--accent); } +.note-pop .who .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } +.note-pop p { margin: 0 0 4px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; } +.doc-thumb { display: grid; place-items: center; color: var(--accent); } +.doc-thumb svg { width: 22px; height: 22px; } + +/* Commenting: a button by the selection, then a small box to write in. */ +.doc-comment-btn { position: fixed; z-index: 60; display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border-radius: 9px; font-size: 13px; font-weight: 500; color: var(--invert-text); background: var(--invert-bg); box-shadow: var(--shadow); animation: pop 0.14s var(--ease); } +.doc-comment-btn svg { width: 14px; height: 14px; } +.doc-comment-box { position: fixed; z-index: 60; width: 280px; padding: 12px; border-radius: 12px; border: 1px solid var(--border-strong); background: var(--surface); box-shadow: var(--shadow); animation: pop 0.16s var(--ease); } +.doc-comment-box blockquote { margin: 0 0 8px; padding-left: 10px; border-left: 3px solid var(--warn); font-size: 12.5px; color: var(--text-3); max-height: 60px; overflow: hidden; } +.doc-comment-box textarea { width: 100%; resize: vertical; min-height: 64px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: 14px/1.45 var(--sans); outline: 0; } +.doc-comment-box textarea:focus { border-color: var(--border-strong); } +.doc-comment-box .row { display: flex; align-items: center; gap: 6px; margin-top: 8px; } +.doc-comment-box .hint { flex: 1; font-size: 11.5px; color: var(--text-3); } + +/* The comments waiting to go, at the foot of the panel. */ +.doc-foot { position: absolute; left: 0; right: 0; bottom: 0; height: var(--foot, 220px); display: flex; flex-direction: column; border-top: 1px solid var(--border); background: var(--surface); } +.doc-foot[hidden] { display: none; } +.doc-foot-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); } +.doc-foot-head b { font-size: 13.5px; } +.doc-foot-head .spacer, .doc-editbar .spacer { flex: 1; } +/* The footer reads as a chat: Flash on the left, the user in bubbles + on the right, each under the passage it is about. */ +.doc-comments { list-style: none; margin: 0; padding: 12px 14px 16px; overflow: auto; flex: 1; display: flex; flex-direction: column; gap: 14px; } +.doc-comments li { display: flex; flex-direction: column; align-items: flex-start; max-width: 100%; animation: rise 0.22s var(--ease) both; } +.doc-comments li.mine { align-items: flex-end; } +.doc-comments li.lost .about { opacity: 0.55; } +.doc-comments .who { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; font-size: 12px; font-weight: 600; color: var(--accent); } +.doc-comments .who .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } +.doc-comments .about { display: flex; align-items: baseline; gap: 6px; max-width: 88%; margin-bottom: 5px; padding: 2px 0; font-size: 12px; color: var(--text-3); text-align: left; } +.doc-comments button.about:hover q { color: var(--text-2); } +.doc-comments .where { flex: none; font: 600 11px/1.3 var(--mono); } +.doc-comments .where:empty { display: none; } +.doc-comments q { min-width: 0; padding-left: 7px; border-left: 2px solid var(--warn); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; quotes: none; } +.doc-comments li.theirs q { border-left-color: var(--accent); } +.doc-comments .said { margin: 0; max-width: 92%; font-size: 14px; line-height: 1.55; color: var(--text); white-space: pre-wrap; word-wrap: break-word; } +.doc-comments .replying { margin-bottom: 4px; font-size: 11.5px; color: var(--accent); } +.doc-comments .bubble { margin: 0; max-width: 85%; padding: 8px 13px; border-radius: 16px 16px 5px 16px; border: 1px solid var(--border); background: var(--bg); font-size: 14px; line-height: 1.5; color: var(--text); white-space: pre-wrap; word-wrap: break-word; cursor: text; } +.doc-comments .acts { display: flex; align-items: center; gap: 2px; margin-top: 4px; opacity: 0; transition: opacity 0.15s var(--ease); } +.doc-comments li.theirs .acts { opacity: 1; gap: 4px; } +.doc-comments li:hover .acts, .doc-comments .acts:focus-within { opacity: 1; } +.doc-comments .acts .icon-btn { width: 26px; height: 26px; } +.doc-comments .acts .icon-btn svg { width: 14px; height: 14px; } +.doc-comments .acts .btn { height: 26px; padding: 0 10px; font-size: 12.5px; } +.doc-comments .bubble-edit { width: 85%; resize: none; padding: 8px 13px; border-radius: 16px 16px 5px 16px; border: 1px solid var(--accent); background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); outline: 0; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } +.doc-comments .edit-acts { display: flex; align-items: center; gap: 6px; margin-top: 6px; } +.doc-comments .edit-acts .hint { font-size: 11.5px; color: var(--text-3); margin-right: 4px; } +.doc-comments .edit-acts .btn { height: 26px; padding: 0 10px; font-size: 12.5px; } + +/* Editing in place: the page itself, with a toolbar for the formatting. */ +.doc-editbar { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--surface); font-size: 13.5px; color: var(--text-2); } +.doc-tools { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; min-width: 0; } +.doc-editbar .doc-block { width: 112px; } +.doc-tools .sep { width: 1px; height: 18px; background: var(--border-strong); margin: 0 5px; } +.doc-tool { width: 30px; height: 30px; border-radius: 7px; display: grid; place-items: center; color: var(--text-2); font: 600 14px/1 var(--sans); transition: background 0.12s, color 0.12s; } +.doc-tool svg { width: 16px; height: 16px; } +.doc-tool:hover { background: var(--hover); color: var(--text); } +.doc-tool.on { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); } +.doc-tool.t-italic { font-style: italic; font-family: Georgia, serif; } +.doc-tool.t-underline { text-decoration: underline; text-underline-offset: 3px; } +.doc-tool.t-strikeThrough { text-decoration: line-through; } +.doc-block { height: 30px; padding: 0 26px 0 9px; margin-right: 4px; border-radius: 7px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: 13px var(--sans); cursor: pointer; } +.doc-editable { outline: 0; caret-color: var(--accent); cursor: text; } +.doc-editable:empty::before { content: "Start writing"; color: var(--text-3); } +.doc-editable u, .doc u { text-underline-offset: 3px; } +.doc-editable .code-head { user-select: none; } +.doc-link-box input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: 13.5px var(--mono); outline: 0; } #viewer-body .image.actual img { max-width: none; max-height: none; } /* Search ------------------------------------------------------------- */ @@ -1208,6 +1367,7 @@ @media (max-width: 800px) { #viewer, #viewer.open { position: fixed; inset: 0; z-index: 55; width: 100%; border: 0; pointer-events: none; } #viewer .pane { width: 100%; transform: translateX(100%); opacity: 1; } + .viewer-resize { display: none; } #viewer.open { pointer-events: auto; } #viewer.open .pane { transform: none; } #sidebar { position: fixed; z-index: 50; height: 100%; box-shadow: var(--shadow); } @@ -1296,10 +1456,13 @@