From 268c41172ea761ceb757c6ee26151be8c770df63 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Mon, 23 Mar 2026 19:34:41 +1030 Subject: [PATCH 1/9] feat(botwiz): add marketplace publishing and avatar generation tools Add comprehensive bot publishing workflow to marketplace with GitHub auth and Docker image building. Introduce avatar generation from style refs using xAI Grok Imagine. Key changes: - `publish_marketplace` tool with build/submit_to_review modes - `generate_avatar` tool with style bank seeding and idea-based generation - Backend GraphQL mutations: `botwiz_marketplace_action`, avatar RPCs - Frontend marketplace action menu and OAuth popup handling - Improved expert reuse by fexp_id and provenance tracking - Style bank manifest and default assets in flexus-client-kit --- .../avatar_from_idea_imagine.py | 346 +++++++++++------- .../bot_pictures/style_bank/manifest.json | 27 ++ 2 files changed, 247 insertions(+), 126 deletions(-) create mode 100644 flexus_simple_bots/bot_pictures/style_bank/manifest.json diff --git a/flexus_simple_bots/avatar_from_idea_imagine.py b/flexus_simple_bots/avatar_from_idea_imagine.py index b271ce06..7c281343 100644 --- a/flexus_simple_bots/avatar_from_idea_imagine.py +++ b/flexus_simple_bots/avatar_from_idea_imagine.py @@ -1,38 +1,164 @@ -import asyncio, base64, io, logging +import os, sys, asyncio, base64, io, json +from pathlib import Path from PIL import Image -import xai_sdk +try: + import xai_sdk +except ImportError: + xai_sdk = None + +_default_client = None -log = logging.getLogger("avatar_imagine") DEFAULT_MODEL = "grok-imagine-image" +DEFAULT_RESOLUTION = "1k" +_STYLE_BANK_MANIFEST = Path(__file__).parent / "bot_pictures" / "style_bank" / "manifest.json" + + +def create_xai_client(api_key: str | None = None): + if xai_sdk is None: + raise RuntimeError("xai-sdk package is required") + if api_key: + return xai_sdk.Client(api_key=api_key) + return xai_sdk.Client() + + +def _get_default_client(): + global _default_client + if _default_client is None: + _default_client = create_xai_client() + return _default_client + + +def _image_to_data_url(image_bytes: bytes, mime: str = "image/png") -> str: + return f"data:{mime};base64,{base64.b64encode(image_bytes).decode('utf-8')}" + + +def style_bank_manifest() -> list[dict]: + if not _STYLE_BANK_MANIFEST.exists(): + return [] + with open(_STYLE_BANK_MANIFEST, "r", encoding="utf-8") as f: + rows = json.load(f) + if not isinstance(rows, list): + raise ValueError(f"Bad style-bank manifest: {_STYLE_BANK_MANIFEST}") + return rows + + +def default_style_bank_files() -> dict[str, bytes]: + root = Path(__file__).parent + files = {} + for row in style_bank_manifest(): + rel = str(row.get("source_path", "")).strip() + target_name = str(row.get("target_name", "")).strip() + if not rel or not target_name: + continue + path = root / rel + if not path.exists(): + continue + files[target_name] = path.read_bytes() + return files + + +async def _sample_image( + xclient, + *, + prompt: str, + image_urls: list[str], + resolution: str = DEFAULT_RESOLUTION, +) -> bytes: + kwargs = { + "prompt": prompt, + "model": DEFAULT_MODEL, + "aspect_ratio": None, + "resolution": resolution, + "image_format": "base64", + } + image_urls = image_urls[:5] + if len(image_urls) == 1: + kwargs["image_url"] = image_urls[0] + else: + kwargs["image_urls"] = image_urls + def _api_call(): + return xclient.image.sample(**kwargs) -def _process_image(png_bytes: bytes, target_size: tuple[int, int]) -> tuple[bytes, tuple[int, int]]: + rsp = await asyncio.to_thread(_api_call) + return rsp.image + + +def _save_fullsize_webp_bytes(png_bytes: bytes, quality: int = 85) -> tuple[bytes, tuple[int, int]]: + out = io.BytesIO() with Image.open(io.BytesIO(png_bytes)) as im: - log.info("raw image from API: %dx%d", im.size[0], im.size[1]) im = make_transparent(im) - if im.size != target_size: - im = im.resize(target_size, Image.LANCZOS) - out = io.BytesIO() - im.save(out, "WEBP", quality=85, method=6) - data = out.getvalue() - log.info("processed image: %dx%d, %d bytes webp", im.size[0], im.size[1], len(data)) - return data, im.size + im.save(out, "WEBP", quality=quality, method=6) + size = im.size + return out.getvalue(), size -def _center_crop_square(png_bytes: bytes) -> bytes: - with Image.open(io.BytesIO(png_bytes)) as im: - w, h = im.size - s = min(w, h) - left = (w - s) // 2 - top = (h - s) // 2 - im = im.crop((left, top, left + s, top + s)) - out = io.BytesIO() - im.save(out, "PNG") - return out.getvalue() +def _save_avatar_256_webp_bytes(avatar_png_bytes: bytes, quality: int = 85) -> tuple[bytes, tuple[int, int]]: + out = io.BytesIO() + with Image.open(io.BytesIO(avatar_png_bytes)) as im: + im = make_transparent(im) + s = min(im.size) + cx, cy = im.size[0] // 2, im.size[1] // 2 + im = im.crop((cx - s // 2, cy - s // 2, cx + s // 2, cy + s // 2)).resize((256, 256), Image.LANCZOS) + im.save(out, "WEBP", quality=quality, method=6) + size = im.size + return out.getvalue(), size + + +async def generate_avatar_assets_from_idea( + *, + input_image_bytes: bytes, + description: str, + style_reference_images: list[bytes], + api_key: str, + count: int = 5, +) -> list[dict]: + if not description or not description.strip(): + raise ValueError("description is required") + if count < 1 or count > 10: + raise ValueError("count must be in range [1, 10]") + + xclient = create_xai_client(api_key) + refs = [_image_to_data_url(input_image_bytes)] + refs += [_image_to_data_url(x) for x in style_reference_images] + refs = refs[:5] + + fullsize_prompt = ( + f"{description.strip()}. " + "Create a full-size variation of the character on pure solid bright green background (#00FF00)." + ) + avatar_prompt = ( + f"{description.strip()}. " + "Make avatar suitable for small pictures, face much bigger exactly in the center, " + "use a pure solid bright green background (#00FF00)." + ) + + async def _one(i: int): + fullsize_png = await _sample_image(xclient, prompt=fullsize_prompt, image_urls=refs) + fullsize_webp, fullsize_size = _save_fullsize_webp_bytes(fullsize_png) + + avatar_png = await _sample_image( + xclient, + prompt=avatar_prompt, + image_urls=[_image_to_data_url(fullsize_png)], + ) + avatar_webp_256, avatar_size_256 = _save_avatar_256_webp_bytes(avatar_png) + return { + "index": i, + "fullsize_webp": fullsize_webp, + "fullsize_size": fullsize_size, + "avatar_webp_256": avatar_webp_256, + "avatar_size_256": avatar_size_256, + } + + return await asyncio.gather(*[_one(i) for i in range(count)]) def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): + """Green-screen chroma key with fringe cleanup. + - fringe_fight: green spill suppression (lower = more aggressive, 0.40 default) + - defringe_radius: pixels around the transparent edge to desaturate green from (0 to skip)""" im = im.convert("RGBA") pixels = im.load() w, h = im.size @@ -40,20 +166,27 @@ def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): for y in range(h): for x in range(w): r, g, b, a = pixels[x, y] + if g > r + 38 and g > b + 38 and g > 140: + # Strong background green → fully transparent pixels[x, y] = (255, 255, 255, 0) + elif g > r + 8 and g > b + 8 and g > 85: + # Transition zone — wider catch than before green_bias = g - max(r, b) alpha = max(0, int(255 - (green_bias - 8) * 7.5)) alpha = min(255, alpha) g_clean = max(r, int(g * fringe_fight)) pixels[x, y] = (r, g_clean, b, alpha) + # Second pass: defringe — suppress green on opaque pixels near transparent ones if defringe_radius > 0: import numpy as np arr = np.array(im) alpha_chan = arr[:, :, 3] + # Mask of fully/mostly transparent pixels transparent = alpha_chan < 32 + # Dilate the transparent mask to find border pixels border = np.zeros_like(transparent) for dy in range(-defringe_radius, defringe_radius + 1): for dx in range(-defringe_radius, defringe_radius + 1): @@ -61,8 +194,10 @@ def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): continue shifted = np.roll(np.roll(transparent, dy, axis=0), dx, axis=1) border |= shifted + # Border pixels that are themselves opaque — these are the fringe candidates fringe_mask = border & (alpha_chan > 128) r_ch, g_ch, b_ch = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2] + # Suppress green toward the average of R and B rb_avg = ((r_ch.astype(np.int16) + b_ch.astype(np.int16)) // 2).astype(np.uint8) g_suppressed = np.minimum(g_ch, np.maximum(rb_avg, (g_ch * fringe_fight).astype(np.uint8))) arr[:, :, 1] = np.where(fringe_mask, g_suppressed, g_ch) @@ -71,125 +206,84 @@ def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): return im -async def _sample_image( - xclient, - *, - prompt: str, - image_urls: list[str], - aspect_ratio: str, - resolution: str -) -> bytes: - kwargs = { - "prompt": prompt, - "model": DEFAULT_MODEL, - "aspect_ratio": aspect_ratio, - "resolution": resolution, - "image_format": "base64", - } - image_urls = image_urls[:5] - if len(image_urls) == 1: - kwargs["image_url"] = image_urls[0] - elif image_urls: - kwargs["image_urls"] = image_urls - - log.info("API call: model=%s aspect_ratio=%s resolution=%s refs=%d prompt=%.80s", - DEFAULT_MODEL, aspect_ratio, resolution, len(image_urls), prompt) +async def make_fullsize_variations(input_path: str, base_name: str, out_dir: str) -> list: + with open(input_path, "rb") as f: + raw = f.read() + image_data = base64.b64encode(raw).decode("utf-8") + image_url = f"data:image/png;base64,{image_data}" - def _api_call(): - return xclient.image.sample(**kwargs) + async def generate_one(i): + def api_call(): + return _get_default_client().image.sample( + prompt="Make variations of the charactor on solid bright green background (#00FF00).", + model=DEFAULT_MODEL, + image_url=image_url, + aspect_ratio=None, # does not work for image edit + resolution=DEFAULT_RESOLUTION, + image_format="base64" + ) + rsp = await asyncio.to_thread(api_call) + png_bytes = rsp.image - rsp = await asyncio.to_thread(_api_call) - log.info("API response: %d bytes", len(rsp.image)) - return rsp.image + with Image.open(io.BytesIO(png_bytes)) as im: + im = make_transparent(im) + fn = os.path.join(out_dir, f"i{i:02d}-{base_name}-{im.size[0]}x{im.size[1]}.webp") + im.save(fn, 'WEBP', quality=85, method=6) + print(f"Saved {fn}") + return (i, png_bytes) + tasks = [generate_one(i) for i in range(5)] + return await asyncio.gather(*tasks) -async def generate_avatar_assets_from_idea( - *, - description: str, - fullsize_refs: list[bytes], - avatar_refs: list[bytes], - api_key: str, -) -> list[dict]: - def _image_to_data_url(image_bytes: bytes) -> str: - with Image.open(io.BytesIO(image_bytes)) as im: - mime = f"image/{(im.format or 'png').lower()}" - return f"data:{mime};base64,{base64.b64encode(image_bytes).decode('utf-8')}" - if not description or not description.strip(): - raise ValueError("description is required") +async def make_avatar(i: int, png_bytes: bytes, base_name: str, out_dir: str): + image_url = f"data:image/png;base64,{base64.b64encode(png_bytes).decode('utf-8')}" - xclient = xai_sdk.Client(api_key) - fullsize_ref_urls = [_image_to_data_url(x) for x in fullsize_refs[:5]] - avatar_ref_urls = [_image_to_data_url(x) for x in avatar_refs[:5]] + def api_call(): + return _get_default_client().image.sample( + prompt="Make avatar suitable for small pictures, face much bigger exactly in the center, use a pure solid bright green background (#00FF00).", + model=DEFAULT_MODEL, + image_url=image_url, + aspect_ratio=None, # does not work for image edit + resolution=DEFAULT_RESOLUTION, + image_format="base64" + ) + rsp = await asyncio.to_thread(api_call) + avatar_png = rsp.image - log.info("fullsize_refs: %d images (%s)", len(fullsize_refs), ", ".join(f"{len(x)}b" for x in fullsize_refs[:5])) - log.info("avatar_refs: %d images (%s)", len(avatar_refs), ", ".join(f"{len(x)}b" for x in avatar_refs[:5])) + with Image.open(io.BytesIO(avatar_png)) as im: + im = make_transparent(im) + fn_intermediate = os.path.join(out_dir, f"i{i:02d}-{base_name}-avatar-{im.size[0]}x{im.size[1]}.webp") + im.save(fn_intermediate, 'WEBP', quality=85) + s = min(im.size) + cx, cy = im.size[0] // 2, im.size[1] // 2 + im_cropped = im.crop((cx - s//2, cy - s//2, cx + s//2, cy + s//2)).resize((256, 256), Image.LANCZOS) + fn = os.path.join(out_dir, f"i{i:02d}-{base_name}-avatar-{im_cropped.size[0]}x{im_cropped.size[1]}.webp") + im_cropped.save(fn, 'WEBP', quality=85) + print(f"Saved {fn}") - fullsize_prompt = ( - f"{description.strip()}. " - f"Use given template images and follow the same style. " - "Create a full-size variation of the character on pure solid bright green background (#00FF00)." - ) - avatar_prompt = ( - f"{description.strip()}. " - "Make avatar suitable for small pictures, face much bigger exactly in the center. Use given template images and follow the same style. " - "Keep the same character as in the last picture, use a pure solid bright green background (#00FF00)." - ) - log.info(f"generating fullsize (2:3)...: {fullsize_prompt}") - fullsize_png = await _sample_image(xclient, prompt=fullsize_prompt, image_urls=fullsize_ref_urls, aspect_ratio="2:3", resolution="2k") - fullsize_webp, fullsize_size = _process_image(fullsize_png, (1024, 1536)) - - fullsize_square_png = _center_crop_square(fullsize_png) - avatar_input_urls = avatar_ref_urls + [_image_to_data_url(fullsize_square_png)] - log.info("generating avatar (1:1) with %d refs (%d from bank + 1 cropped fullsize)...: %s", len(avatar_input_urls), len(avatar_ref_urls), avatar_prompt) - avatar_png = await _sample_image(xclient, prompt=avatar_prompt, image_urls=avatar_input_urls[-5:], aspect_ratio="1:1", resolution="1k") - avatar_webp_256, avatar_size_256 = _process_image(avatar_png, (256, 256)) - - return [{ - "index": 0, - "fullsize_webp": fullsize_webp, - "fullsize_size": fullsize_size, - "avatar_webp_256": avatar_webp_256, - "avatar_size_256": avatar_size_256, - }] - - -async def _cli_main(): - import os, sys - if len(sys.argv) < 2: - print("Usage: %s path/to/image.jpg [description]" % sys.argv[0]) +async def main(): + if len(sys.argv) != 2: + print("Usage: %s path/to/image.png" % sys.argv[0]) sys.exit(1) + input_path = sys.argv[1] - description = sys.argv[2] if len(sys.argv) > 2 else "A character variation" - api_key = os.environ.get("XAI_API_KEY", "") - if not api_key: - print("Set XAI_API_KEY environment variable") + if not os.path.exists(input_path): + print(f"Error: {input_path} not found") sys.exit(1) - with Image.open(input_path) as im: - buf = io.BytesIO() - im.save(buf, "WEBP", quality=80) - ref_bytes = buf.getvalue() + out_dir = os.path.dirname(input_path) or "." base_name = os.path.splitext(os.path.basename(input_path))[0] - results = await generate_avatar_assets_from_idea( - description=description, - fullsize_refs=[ref_bytes], - avatar_refs=[ref_bytes], - api_key=api_key, - ) - for r in results: - fn_full = os.path.join(out_dir, f"{base_name}-1024x1536.webp") - fn_avatar = os.path.join(out_dir, f"{base_name}-256x256.webp") - with open(fn_full, "wb") as f: - f.write(r["fullsize_webp"]) - print(f"Saved {fn_full} ({len(r['fullsize_webp'])} bytes)") - with open(fn_avatar, "wb") as f: - f.write(r["avatar_webp_256"]) - print(f"Saved {fn_avatar} ({len(r['avatar_webp_256'])} bytes)") + + print(f"Generating 5 full-size variations...") + fullsize_results = await make_fullsize_variations(input_path, base_name, out_dir) + + print(f"Generating avatars...") + await asyncio.gather(*(make_avatar(i, png_bytes, base_name, out_dir) for i, png_bytes in fullsize_results)) + print("Done!") if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - asyncio.run(_cli_main()) + asyncio.run(main()) diff --git a/flexus_simple_bots/bot_pictures/style_bank/manifest.json b/flexus_simple_bots/bot_pictures/style_bank/manifest.json new file mode 100644 index 00000000..775907e1 --- /dev/null +++ b/flexus_simple_bots/bot_pictures/style_bank/manifest.json @@ -0,0 +1,27 @@ +[ + { + "target_name": "frog.webp", + "source_path": "frog/frog-256x256.webp", + "label": "Cute mascot style" + }, + { + "target_name": "strategist.webp", + "source_path": "strategist/strategist-256x256.webp", + "label": "Professional portrait style" + }, + { + "target_name": "ad_monster.webp", + "source_path": "admonster/ad_monster-256x256.webp", + "label": "Playful monster style" + }, + { + "target_name": "karen.webp", + "source_path": "karen/karen-256x256.webp", + "label": "Clean assistant style" + }, + { + "target_name": "boss.webp", + "source_path": "boss/boss-256x256.webp", + "label": "Founder portrait style" + } +] From c0040378bba660cdf58e956843c2b1775f01f050 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Wed, 25 Mar 2026 22:28:25 +1030 Subject: [PATCH 2/9] feat(avatar): add rescaling to target size for fullsize WebP output Introduce _FULLSIZE_TARGET (1024x1536) and resize logic that: - Scales images proportionally to fit within target dimensions - Centers the scaled image on a transparent canvas - Applies to both WebP conversion and direct file saving Ensures consistent output dimensions while preserving aspect ratio. --- .../avatar_from_idea_imagine.py | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/flexus_simple_bots/avatar_from_idea_imagine.py b/flexus_simple_bots/avatar_from_idea_imagine.py index 7c281343..9d284155 100644 --- a/flexus_simple_bots/avatar_from_idea_imagine.py +++ b/flexus_simple_bots/avatar_from_idea_imagine.py @@ -85,10 +85,26 @@ def _api_call(): return rsp.image -def _save_fullsize_webp_bytes(png_bytes: bytes, quality: int = 85) -> tuple[bytes, tuple[int, int]]: - out = io.BytesIO() +_FULLSIZE_TARGET = (1024, 1536) + + +def _save_fullsize_webp_bytes( + png_bytes: bytes, + quality: int = 85, + target_size: tuple[int, int] = _FULLSIZE_TARGET, +) -> tuple[bytes, tuple[int, int]]: + tw, th = target_size with Image.open(io.BytesIO(png_bytes)) as im: im = make_transparent(im) + iw, ih = im.size + if (iw, ih) != (tw, th): + scale = min(tw / iw, th / ih) + new_w, new_h = int(iw * scale), int(ih * scale) + im = im.resize((new_w, new_h), Image.LANCZOS) + canvas = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) + canvas.paste(im, ((tw - new_w) // 2, (th - new_h) // 2)) + im = canvas + out = io.BytesIO() im.save(out, "WEBP", quality=quality, method=6) size = im.size return out.getvalue(), size @@ -225,8 +241,17 @@ def api_call(): rsp = await asyncio.to_thread(api_call) png_bytes = rsp.image + tw, th = _FULLSIZE_TARGET with Image.open(io.BytesIO(png_bytes)) as im: im = make_transparent(im) + iw, ih = im.size + if (iw, ih) != (tw, th): + scale = min(tw / iw, th / ih) + new_w, new_h = int(iw * scale), int(ih * scale) + im = im.resize((new_w, new_h), Image.LANCZOS) + canvas = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) + canvas.paste(im, ((tw - new_w) // 2, (th - new_h) // 2)) + im = canvas fn = os.path.join(out_dir, f"i{i:02d}-{base_name}-{im.size[0]}x{im.size[1]}.webp") im.save(fn, 'WEBP', quality=85, method=6) print(f"Saved {fn}") From 498f5c00e25db35b81ccc0d1f52117ed6387a9e8 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Wed, 25 Mar 2026 22:33:48 +1030 Subject: [PATCH 3/9] feat(avatar): add WebP encoding with automatic size optimization Introduce _encode_webp_within_limit() to progressively reduce quality from 100 down to 40 until image fits within 250KB limit, preventing upload failures due to oversized files. Replace direct WebP saves with this function in fullsize and 256px avatar generation. --- .../avatar_from_idea_imagine.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/flexus_simple_bots/avatar_from_idea_imagine.py b/flexus_simple_bots/avatar_from_idea_imagine.py index 9d284155..1d5c8072 100644 --- a/flexus_simple_bots/avatar_from_idea_imagine.py +++ b/flexus_simple_bots/avatar_from_idea_imagine.py @@ -86,11 +86,29 @@ def _api_call(): _FULLSIZE_TARGET = (1024, 1536) +_MAX_IMAGE_BYTES = 250_000 + + +def _encode_webp_within_limit( + im: Image.Image, + quality: int = 100, + max_bytes: int = _MAX_IMAGE_BYTES, + min_quality: int = 40, +) -> bytes: + for q in range(quality, min_quality - 1, -5): + out = io.BytesIO() + im.save(out, "WEBP", quality=q, method=6) + data = out.getvalue() + if len(data) <= max_bytes: + return data + out = io.BytesIO() + im.save(out, "WEBP", quality=min_quality, method=6) + return out.getvalue() def _save_fullsize_webp_bytes( png_bytes: bytes, - quality: int = 85, + quality: int = 100, target_size: tuple[int, int] = _FULLSIZE_TARGET, ) -> tuple[bytes, tuple[int, int]]: tw, th = target_size @@ -104,22 +122,20 @@ def _save_fullsize_webp_bytes( canvas = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) canvas.paste(im, ((tw - new_w) // 2, (th - new_h) // 2)) im = canvas - out = io.BytesIO() - im.save(out, "WEBP", quality=quality, method=6) + data = _encode_webp_within_limit(im, quality) size = im.size - return out.getvalue(), size + return data, size -def _save_avatar_256_webp_bytes(avatar_png_bytes: bytes, quality: int = 85) -> tuple[bytes, tuple[int, int]]: - out = io.BytesIO() +def _save_avatar_256_webp_bytes(avatar_png_bytes: bytes, quality: int = 100) -> tuple[bytes, tuple[int, int]]: with Image.open(io.BytesIO(avatar_png_bytes)) as im: im = make_transparent(im) s = min(im.size) cx, cy = im.size[0] // 2, im.size[1] // 2 im = im.crop((cx - s // 2, cy - s // 2, cx + s // 2, cy + s // 2)).resize((256, 256), Image.LANCZOS) - im.save(out, "WEBP", quality=quality, method=6) + data = _encode_webp_within_limit(im, quality) size = im.size - return out.getvalue(), size + return data, size async def generate_avatar_assets_from_idea( From ef8599bc0f06f5244cc5cea967125b395b9b4df1 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Fri, 27 Mar 2026 01:51:05 +1030 Subject: [PATCH 4/9] feat(mongo-file): add file download cards and preview functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `render` operation to mongo_store tool for generating download cards - Implement backend support for dynamic Content-Disposition (inline/attachment) - Create FileDownloadCard and FilePreviewDialog Vue components - Add frontend parsing for 📎DOWNLOAD: protocol in tool responses - Support preview for images, PDF, HTML, SVG, text/JSON/YAML files (5MB limit) - Enhance MIME type detection with SVG, MD, YAML, XML support - Add i18n support for download/preview UI (en/es/pt) - Auto-generate download cards in Python executor artifacts --- .../integrations/fi_mongo_store.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index caa65ee0..1878229d 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -2,6 +2,7 @@ import os import logging import re +import urllib.parse from typing import Dict, Any, Optional from flexus_client_kit.integrations.fi_localfile import _validate_file_security @@ -19,8 +20,8 @@ "properties": { "op": { "type": "string", - "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save"], - "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly)", + "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save", "render"], + "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly), render (show download card to user)", }, "args": { "type": "object", @@ -65,21 +66,42 @@ Sometimes you need to grep .json files on disk, remember that all the strings inside are escaped in that case, making it a bit harder to match. +render - Show a download card to the user for an already-stored file. + The user sees a styled card with file icon, name, and download/preview button. + args: path (required) + Examples: mongo_store(op="list", args={"path": "folder1/"}) mongo_store(op="cat", args={"path": "folder1/something_20250803.json", "lines_range": "0:40", "safety_valve": "10k"}) mongo_store(op="save", args={"path": "investigations/abc123.json", "content": "{...json...}"}) mongo_store(op="delete", args={"path": "folder1/something_20250803.json"}) mongo_store(op="grep", args={"path": "tasks.txt", "pattern": "TODO", "context": 2}) + mongo_store(op="render", args={"path": "reports/monthly.pdf"}) """ # There's also a secret op="undelete" command that can bring deleted files +_MIME_TYPES = { + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", + ".pdf": "application/pdf", ".txt": "text/plain", ".csv": "text/csv", + ".json": "application/json", ".html": "text/html", ".htm": "text/html", + ".xml": "application/xml", ".md": "text/markdown", + ".yaml": "application/yaml", ".yml": "application/yaml", +} + + +def _guess_mime_type(path: str) -> str: + ext = os.path.splitext(path)[1].lower() + return _MIME_TYPES.get(ext, "application/octet-stream") + + async def handle_mongo_store( rcx, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]], + persona_id: Optional[str] = None, ) -> str: if rcx.running_test_scenario: from flexus_client_kit import ckit_scenario @@ -212,6 +234,23 @@ async def handle_mongo_store( else: return f"Error: File {path} not found in MongoDB" + elif op == "render": + if not path: + return f"Error: path parameter required for render operation\n\n{HELP}" + if not persona_id: + return "Error: render operation requires persona_id (pass it to handle_mongo_store)" + path_error = validate_path(path) + if path_error: + return f"Error: {path_error}" + document = await ckit_mongo.mongo_retrieve_file(mongo_collection, path) + if not document: + return f"Error: File {path} not found in MongoDB" + display_name = os.path.basename(path) + mime = _guess_mime_type(path) + enc_path = urllib.parse.quote(path, safe="/") + enc_name = urllib.parse.quote(display_name, safe="") + return f"📎DOWNLOAD:{persona_id}:{enc_path}:{enc_name}:{mime}" + else: return HELP From a975942e1ab2c80c00cca996a137b83b9990e1b0 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 14 Apr 2026 21:55:42 +0930 Subject: [PATCH 5/9] refactor(avatar): undo unnecessary changes --- .../avatar_from_idea_imagine.py | 385 ++++++------------ .../bot_pictures/style_bank/manifest.json | 27 -- 2 files changed, 125 insertions(+), 287 deletions(-) delete mode 100644 flexus_simple_bots/bot_pictures/style_bank/manifest.json diff --git a/flexus_simple_bots/avatar_from_idea_imagine.py b/flexus_simple_bots/avatar_from_idea_imagine.py index 1d5c8072..b271ce06 100644 --- a/flexus_simple_bots/avatar_from_idea_imagine.py +++ b/flexus_simple_bots/avatar_from_idea_imagine.py @@ -1,196 +1,38 @@ -import os, sys, asyncio, base64, io, json -from pathlib import Path +import asyncio, base64, io, logging from PIL import Image -try: - import xai_sdk -except ImportError: - xai_sdk = None - -_default_client = None +import xai_sdk +log = logging.getLogger("avatar_imagine") DEFAULT_MODEL = "grok-imagine-image" -DEFAULT_RESOLUTION = "1k" -_STYLE_BANK_MANIFEST = Path(__file__).parent / "bot_pictures" / "style_bank" / "manifest.json" - - -def create_xai_client(api_key: str | None = None): - if xai_sdk is None: - raise RuntimeError("xai-sdk package is required") - if api_key: - return xai_sdk.Client(api_key=api_key) - return xai_sdk.Client() - - -def _get_default_client(): - global _default_client - if _default_client is None: - _default_client = create_xai_client() - return _default_client - - -def _image_to_data_url(image_bytes: bytes, mime: str = "image/png") -> str: - return f"data:{mime};base64,{base64.b64encode(image_bytes).decode('utf-8')}" - - -def style_bank_manifest() -> list[dict]: - if not _STYLE_BANK_MANIFEST.exists(): - return [] - with open(_STYLE_BANK_MANIFEST, "r", encoding="utf-8") as f: - rows = json.load(f) - if not isinstance(rows, list): - raise ValueError(f"Bad style-bank manifest: {_STYLE_BANK_MANIFEST}") - return rows - - -def default_style_bank_files() -> dict[str, bytes]: - root = Path(__file__).parent - files = {} - for row in style_bank_manifest(): - rel = str(row.get("source_path", "")).strip() - target_name = str(row.get("target_name", "")).strip() - if not rel or not target_name: - continue - path = root / rel - if not path.exists(): - continue - files[target_name] = path.read_bytes() - return files - - -async def _sample_image( - xclient, - *, - prompt: str, - image_urls: list[str], - resolution: str = DEFAULT_RESOLUTION, -) -> bytes: - kwargs = { - "prompt": prompt, - "model": DEFAULT_MODEL, - "aspect_ratio": None, - "resolution": resolution, - "image_format": "base64", - } - image_urls = image_urls[:5] - if len(image_urls) == 1: - kwargs["image_url"] = image_urls[0] - else: - kwargs["image_urls"] = image_urls - - def _api_call(): - return xclient.image.sample(**kwargs) - - rsp = await asyncio.to_thread(_api_call) - return rsp.image - - -_FULLSIZE_TARGET = (1024, 1536) -_MAX_IMAGE_BYTES = 250_000 -def _encode_webp_within_limit( - im: Image.Image, - quality: int = 100, - max_bytes: int = _MAX_IMAGE_BYTES, - min_quality: int = 40, -) -> bytes: - for q in range(quality, min_quality - 1, -5): +def _process_image(png_bytes: bytes, target_size: tuple[int, int]) -> tuple[bytes, tuple[int, int]]: + with Image.open(io.BytesIO(png_bytes)) as im: + log.info("raw image from API: %dx%d", im.size[0], im.size[1]) + im = make_transparent(im) + if im.size != target_size: + im = im.resize(target_size, Image.LANCZOS) out = io.BytesIO() - im.save(out, "WEBP", quality=q, method=6) + im.save(out, "WEBP", quality=85, method=6) data = out.getvalue() - if len(data) <= max_bytes: - return data - out = io.BytesIO() - im.save(out, "WEBP", quality=min_quality, method=6) - return out.getvalue() + log.info("processed image: %dx%d, %d bytes webp", im.size[0], im.size[1], len(data)) + return data, im.size -def _save_fullsize_webp_bytes( - png_bytes: bytes, - quality: int = 100, - target_size: tuple[int, int] = _FULLSIZE_TARGET, -) -> tuple[bytes, tuple[int, int]]: - tw, th = target_size +def _center_crop_square(png_bytes: bytes) -> bytes: with Image.open(io.BytesIO(png_bytes)) as im: - im = make_transparent(im) - iw, ih = im.size - if (iw, ih) != (tw, th): - scale = min(tw / iw, th / ih) - new_w, new_h = int(iw * scale), int(ih * scale) - im = im.resize((new_w, new_h), Image.LANCZOS) - canvas = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) - canvas.paste(im, ((tw - new_w) // 2, (th - new_h) // 2)) - im = canvas - data = _encode_webp_within_limit(im, quality) - size = im.size - return data, size - - -def _save_avatar_256_webp_bytes(avatar_png_bytes: bytes, quality: int = 100) -> tuple[bytes, tuple[int, int]]: - with Image.open(io.BytesIO(avatar_png_bytes)) as im: - im = make_transparent(im) - s = min(im.size) - cx, cy = im.size[0] // 2, im.size[1] // 2 - im = im.crop((cx - s // 2, cy - s // 2, cx + s // 2, cy + s // 2)).resize((256, 256), Image.LANCZOS) - data = _encode_webp_within_limit(im, quality) - size = im.size - return data, size - - -async def generate_avatar_assets_from_idea( - *, - input_image_bytes: bytes, - description: str, - style_reference_images: list[bytes], - api_key: str, - count: int = 5, -) -> list[dict]: - if not description or not description.strip(): - raise ValueError("description is required") - if count < 1 or count > 10: - raise ValueError("count must be in range [1, 10]") - - xclient = create_xai_client(api_key) - refs = [_image_to_data_url(input_image_bytes)] - refs += [_image_to_data_url(x) for x in style_reference_images] - refs = refs[:5] - - fullsize_prompt = ( - f"{description.strip()}. " - "Create a full-size variation of the character on pure solid bright green background (#00FF00)." - ) - avatar_prompt = ( - f"{description.strip()}. " - "Make avatar suitable for small pictures, face much bigger exactly in the center, " - "use a pure solid bright green background (#00FF00)." - ) - - async def _one(i: int): - fullsize_png = await _sample_image(xclient, prompt=fullsize_prompt, image_urls=refs) - fullsize_webp, fullsize_size = _save_fullsize_webp_bytes(fullsize_png) - - avatar_png = await _sample_image( - xclient, - prompt=avatar_prompt, - image_urls=[_image_to_data_url(fullsize_png)], - ) - avatar_webp_256, avatar_size_256 = _save_avatar_256_webp_bytes(avatar_png) - return { - "index": i, - "fullsize_webp": fullsize_webp, - "fullsize_size": fullsize_size, - "avatar_webp_256": avatar_webp_256, - "avatar_size_256": avatar_size_256, - } - - return await asyncio.gather(*[_one(i) for i in range(count)]) + w, h = im.size + s = min(w, h) + left = (w - s) // 2 + top = (h - s) // 2 + im = im.crop((left, top, left + s, top + s)) + out = io.BytesIO() + im.save(out, "PNG") + return out.getvalue() def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): - """Green-screen chroma key with fringe cleanup. - - fringe_fight: green spill suppression (lower = more aggressive, 0.40 default) - - defringe_radius: pixels around the transparent edge to desaturate green from (0 to skip)""" im = im.convert("RGBA") pixels = im.load() w, h = im.size @@ -198,27 +40,20 @@ def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): for y in range(h): for x in range(w): r, g, b, a = pixels[x, y] - if g > r + 38 and g > b + 38 and g > 140: - # Strong background green → fully transparent pixels[x, y] = (255, 255, 255, 0) - elif g > r + 8 and g > b + 8 and g > 85: - # Transition zone — wider catch than before green_bias = g - max(r, b) alpha = max(0, int(255 - (green_bias - 8) * 7.5)) alpha = min(255, alpha) g_clean = max(r, int(g * fringe_fight)) pixels[x, y] = (r, g_clean, b, alpha) - # Second pass: defringe — suppress green on opaque pixels near transparent ones if defringe_radius > 0: import numpy as np arr = np.array(im) alpha_chan = arr[:, :, 3] - # Mask of fully/mostly transparent pixels transparent = alpha_chan < 32 - # Dilate the transparent mask to find border pixels border = np.zeros_like(transparent) for dy in range(-defringe_radius, defringe_radius + 1): for dx in range(-defringe_radius, defringe_radius + 1): @@ -226,10 +61,8 @@ def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): continue shifted = np.roll(np.roll(transparent, dy, axis=0), dx, axis=1) border |= shifted - # Border pixels that are themselves opaque — these are the fringe candidates fringe_mask = border & (alpha_chan > 128) r_ch, g_ch, b_ch = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2] - # Suppress green toward the average of R and B rb_avg = ((r_ch.astype(np.int16) + b_ch.astype(np.int16)) // 2).astype(np.uint8) g_suppressed = np.minimum(g_ch, np.maximum(rb_avg, (g_ch * fringe_fight).astype(np.uint8))) arr[:, :, 1] = np.where(fringe_mask, g_suppressed, g_ch) @@ -238,93 +71,125 @@ def make_transparent(im, fringe_fight: float = 0.30, defringe_radius: int = 4): return im -async def make_fullsize_variations(input_path: str, base_name: str, out_dir: str) -> list: - with open(input_path, "rb") as f: - raw = f.read() - image_data = base64.b64encode(raw).decode("utf-8") - image_url = f"data:image/png;base64,{image_data}" +async def _sample_image( + xclient, + *, + prompt: str, + image_urls: list[str], + aspect_ratio: str, + resolution: str +) -> bytes: + kwargs = { + "prompt": prompt, + "model": DEFAULT_MODEL, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "image_format": "base64", + } + image_urls = image_urls[:5] + if len(image_urls) == 1: + kwargs["image_url"] = image_urls[0] + elif image_urls: + kwargs["image_urls"] = image_urls + + log.info("API call: model=%s aspect_ratio=%s resolution=%s refs=%d prompt=%.80s", + DEFAULT_MODEL, aspect_ratio, resolution, len(image_urls), prompt) - async def generate_one(i): - def api_call(): - return _get_default_client().image.sample( - prompt="Make variations of the charactor on solid bright green background (#00FF00).", - model=DEFAULT_MODEL, - image_url=image_url, - aspect_ratio=None, # does not work for image edit - resolution=DEFAULT_RESOLUTION, - image_format="base64" - ) - rsp = await asyncio.to_thread(api_call) - png_bytes = rsp.image + def _api_call(): + return xclient.image.sample(**kwargs) - tw, th = _FULLSIZE_TARGET - with Image.open(io.BytesIO(png_bytes)) as im: - im = make_transparent(im) - iw, ih = im.size - if (iw, ih) != (tw, th): - scale = min(tw / iw, th / ih) - new_w, new_h = int(iw * scale), int(ih * scale) - im = im.resize((new_w, new_h), Image.LANCZOS) - canvas = Image.new("RGBA", (tw, th), (0, 0, 0, 0)) - canvas.paste(im, ((tw - new_w) // 2, (th - new_h) // 2)) - im = canvas - fn = os.path.join(out_dir, f"i{i:02d}-{base_name}-{im.size[0]}x{im.size[1]}.webp") - im.save(fn, 'WEBP', quality=85, method=6) - print(f"Saved {fn}") - return (i, png_bytes) + rsp = await asyncio.to_thread(_api_call) + log.info("API response: %d bytes", len(rsp.image)) + return rsp.image - tasks = [generate_one(i) for i in range(5)] - return await asyncio.gather(*tasks) +async def generate_avatar_assets_from_idea( + *, + description: str, + fullsize_refs: list[bytes], + avatar_refs: list[bytes], + api_key: str, +) -> list[dict]: + def _image_to_data_url(image_bytes: bytes) -> str: + with Image.open(io.BytesIO(image_bytes)) as im: + mime = f"image/{(im.format or 'png').lower()}" + return f"data:{mime};base64,{base64.b64encode(image_bytes).decode('utf-8')}" -async def make_avatar(i: int, png_bytes: bytes, base_name: str, out_dir: str): - image_url = f"data:image/png;base64,{base64.b64encode(png_bytes).decode('utf-8')}" + if not description or not description.strip(): + raise ValueError("description is required") - def api_call(): - return _get_default_client().image.sample( - prompt="Make avatar suitable for small pictures, face much bigger exactly in the center, use a pure solid bright green background (#00FF00).", - model=DEFAULT_MODEL, - image_url=image_url, - aspect_ratio=None, # does not work for image edit - resolution=DEFAULT_RESOLUTION, - image_format="base64" - ) - rsp = await asyncio.to_thread(api_call) - avatar_png = rsp.image + xclient = xai_sdk.Client(api_key) + fullsize_ref_urls = [_image_to_data_url(x) for x in fullsize_refs[:5]] + avatar_ref_urls = [_image_to_data_url(x) for x in avatar_refs[:5]] - with Image.open(io.BytesIO(avatar_png)) as im: - im = make_transparent(im) - fn_intermediate = os.path.join(out_dir, f"i{i:02d}-{base_name}-avatar-{im.size[0]}x{im.size[1]}.webp") - im.save(fn_intermediate, 'WEBP', quality=85) - s = min(im.size) - cx, cy = im.size[0] // 2, im.size[1] // 2 - im_cropped = im.crop((cx - s//2, cy - s//2, cx + s//2, cy + s//2)).resize((256, 256), Image.LANCZOS) - fn = os.path.join(out_dir, f"i{i:02d}-{base_name}-avatar-{im_cropped.size[0]}x{im_cropped.size[1]}.webp") - im_cropped.save(fn, 'WEBP', quality=85) - print(f"Saved {fn}") + log.info("fullsize_refs: %d images (%s)", len(fullsize_refs), ", ".join(f"{len(x)}b" for x in fullsize_refs[:5])) + log.info("avatar_refs: %d images (%s)", len(avatar_refs), ", ".join(f"{len(x)}b" for x in avatar_refs[:5])) + fullsize_prompt = ( + f"{description.strip()}. " + f"Use given template images and follow the same style. " + "Create a full-size variation of the character on pure solid bright green background (#00FF00)." + ) + avatar_prompt = ( + f"{description.strip()}. " + "Make avatar suitable for small pictures, face much bigger exactly in the center. Use given template images and follow the same style. " + "Keep the same character as in the last picture, use a pure solid bright green background (#00FF00)." + ) -async def main(): - if len(sys.argv) != 2: - print("Usage: %s path/to/image.png" % sys.argv[0]) + log.info(f"generating fullsize (2:3)...: {fullsize_prompt}") + fullsize_png = await _sample_image(xclient, prompt=fullsize_prompt, image_urls=fullsize_ref_urls, aspect_ratio="2:3", resolution="2k") + fullsize_webp, fullsize_size = _process_image(fullsize_png, (1024, 1536)) + + fullsize_square_png = _center_crop_square(fullsize_png) + avatar_input_urls = avatar_ref_urls + [_image_to_data_url(fullsize_square_png)] + log.info("generating avatar (1:1) with %d refs (%d from bank + 1 cropped fullsize)...: %s", len(avatar_input_urls), len(avatar_ref_urls), avatar_prompt) + avatar_png = await _sample_image(xclient, prompt=avatar_prompt, image_urls=avatar_input_urls[-5:], aspect_ratio="1:1", resolution="1k") + avatar_webp_256, avatar_size_256 = _process_image(avatar_png, (256, 256)) + + return [{ + "index": 0, + "fullsize_webp": fullsize_webp, + "fullsize_size": fullsize_size, + "avatar_webp_256": avatar_webp_256, + "avatar_size_256": avatar_size_256, + }] + + +async def _cli_main(): + import os, sys + if len(sys.argv) < 2: + print("Usage: %s path/to/image.jpg [description]" % sys.argv[0]) sys.exit(1) - input_path = sys.argv[1] - if not os.path.exists(input_path): - print(f"Error: {input_path} not found") + description = sys.argv[2] if len(sys.argv) > 2 else "A character variation" + api_key = os.environ.get("XAI_API_KEY", "") + if not api_key: + print("Set XAI_API_KEY environment variable") sys.exit(1) - + with Image.open(input_path) as im: + buf = io.BytesIO() + im.save(buf, "WEBP", quality=80) + ref_bytes = buf.getvalue() out_dir = os.path.dirname(input_path) or "." base_name = os.path.splitext(os.path.basename(input_path))[0] - - print(f"Generating 5 full-size variations...") - fullsize_results = await make_fullsize_variations(input_path, base_name, out_dir) - - print(f"Generating avatars...") - await asyncio.gather(*(make_avatar(i, png_bytes, base_name, out_dir) for i, png_bytes in fullsize_results)) - + results = await generate_avatar_assets_from_idea( + description=description, + fullsize_refs=[ref_bytes], + avatar_refs=[ref_bytes], + api_key=api_key, + ) + for r in results: + fn_full = os.path.join(out_dir, f"{base_name}-1024x1536.webp") + fn_avatar = os.path.join(out_dir, f"{base_name}-256x256.webp") + with open(fn_full, "wb") as f: + f.write(r["fullsize_webp"]) + print(f"Saved {fn_full} ({len(r['fullsize_webp'])} bytes)") + with open(fn_avatar, "wb") as f: + f.write(r["avatar_webp_256"]) + print(f"Saved {fn_avatar} ({len(r['avatar_webp_256'])} bytes)") print("Done!") if __name__ == "__main__": - asyncio.run(main()) + logging.basicConfig(level=logging.INFO) + asyncio.run(_cli_main()) diff --git a/flexus_simple_bots/bot_pictures/style_bank/manifest.json b/flexus_simple_bots/bot_pictures/style_bank/manifest.json deleted file mode 100644 index 775907e1..00000000 --- a/flexus_simple_bots/bot_pictures/style_bank/manifest.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "target_name": "frog.webp", - "source_path": "frog/frog-256x256.webp", - "label": "Cute mascot style" - }, - { - "target_name": "strategist.webp", - "source_path": "strategist/strategist-256x256.webp", - "label": "Professional portrait style" - }, - { - "target_name": "ad_monster.webp", - "source_path": "admonster/ad_monster-256x256.webp", - "label": "Playful monster style" - }, - { - "target_name": "karen.webp", - "source_path": "karen/karen-256x256.webp", - "label": "Clean assistant style" - }, - { - "target_name": "boss.webp", - "source_path": "boss/boss-256x256.webp", - "label": "Founder portrait style" - } -] From b2782ed9b5e11bb0c83d86d01577012613b17fe2 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 14 Apr 2026 21:57:39 +0930 Subject: [PATCH 6/9] refactor(fi-mongo-store): rename render operation to render_download_link - Update schema enum, docstring, examples, and error messages - Remove redundant file existence check in render_download_link handler --- .../integrations/fi_mongo_store.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index 1878229d..b48d0e60 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -20,8 +20,8 @@ "properties": { "op": { "type": "string", - "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save", "render"], - "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly), render (show download card to user)", + "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save", "render_download_link"], + "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly), render_download_link (show download card to user)", }, "args": { "type": "object", @@ -66,7 +66,7 @@ Sometimes you need to grep .json files on disk, remember that all the strings inside are escaped in that case, making it a bit harder to match. -render - Show a download card to the user for an already-stored file. +render_download_link - Show a download card to the user for an already-stored file. The user sees a styled card with file icon, name, and download/preview button. args: path (required) @@ -76,7 +76,7 @@ mongo_store(op="save", args={"path": "investigations/abc123.json", "content": "{...json...}"}) mongo_store(op="delete", args={"path": "folder1/something_20250803.json"}) mongo_store(op="grep", args={"path": "tasks.txt", "pattern": "TODO", "context": 2}) - mongo_store(op="render", args={"path": "reports/monthly.pdf"}) + mongo_store(op="render_download_link", args={"path": "reports/monthly.pdf"}) """ # There's also a secret op="undelete" command that can bring deleted files @@ -234,17 +234,14 @@ async def handle_mongo_store( else: return f"Error: File {path} not found in MongoDB" - elif op == "render": + elif op == "render_download_link": if not path: - return f"Error: path parameter required for render operation\n\n{HELP}" + return f"Error: path parameter required for `render_download_link` operation\n\n{HELP}" if not persona_id: - return "Error: render operation requires persona_id (pass it to handle_mongo_store)" + return "Error: `render_download_link` operation requires persona_id (pass it to handle_mongo_store)" path_error = validate_path(path) if path_error: return f"Error: {path_error}" - document = await ckit_mongo.mongo_retrieve_file(mongo_collection, path) - if not document: - return f"Error: File {path} not found in MongoDB" display_name = os.path.basename(path) mime = _guess_mime_type(path) enc_path = urllib.parse.quote(path, safe="/") From 58ce7b6ae2f5df60bfac80c2d801b946006a6fb5 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 14 Apr 2026 22:23:55 +0930 Subject: [PATCH 7/9] feat(chat): add quicksession auth for file downloads and previews - Use quicksession store to add `Authorization: Session` header in FileDownloadCard and FilePreviewDialog - Fetch files as blobs via authFetch helper for secure download/preview - Support blob URLs for image/PDF previews with proper cleanup - Update backend fi_mongo_store to derive persona_id from toolcall.connected_persona_id --- flexus_client_kit/integrations/fi_mongo_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index b48d0e60..b49b3834 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -101,7 +101,6 @@ async def handle_mongo_store( rcx, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]], - persona_id: Optional[str] = None, ) -> str: if rcx.running_test_scenario: from flexus_client_kit import ckit_scenario @@ -237,8 +236,9 @@ async def handle_mongo_store( elif op == "render_download_link": if not path: return f"Error: path parameter required for `render_download_link` operation\n\n{HELP}" + persona_id = toolcall.connected_persona_id if not persona_id: - return "Error: `render_download_link` operation requires persona_id (pass it to handle_mongo_store)" + return "Error: `render_download_link` operation requires persona_id (connected_persona_id missing from toolcall)" path_error = validate_path(path) if path_error: return f"Error: {path_error}" From 3d67b96a79a32cd97244ae91d26db450b01ab55b Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 14 Apr 2026 22:49:21 +0930 Subject: [PATCH 8/9] feat(fi_mongo_store): add patch operation for exact string replacement - Adds `patch` op to schema, args (`old_text`, `new_text`), and help text/example - Implements find-and-replace: requires exact single match, text files only (not JSON), validates UTF-8, overwrites file on success --- .../integrations/fi_mongo_store.py | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index b49b3834..73798b0b 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -20,8 +20,8 @@ "properties": { "op": { "type": "string", - "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save", "render_download_link"], - "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly), render_download_link (show download card to user)", + "enum": ["help", "list", "ls", "cat", "grep", "delete", "upload", "save", "patch", "render_download_link"], + "description": "Operation: list/ls (list files), cat (read file), grep (search), delete, upload (from disk), save (content directly), patch (find-and-replace in a file), render_download_link (show download card to user)", }, "args": { "type": "object", @@ -34,8 +34,10 @@ "pattern": {"type": ["string", "null"], "description": "Python regex pattern for grep"}, "context": {"type": ["integer", "null"], "description": "Context lines around grep matches"}, "content": {"type": ["string", "null"], "description": "Content for save op (JSON string or text)"}, + "old_text": {"type": ["string", "null"], "description": "For patch: exact text to find"}, + "new_text": {"type": ["string", "null"], "description": "For patch: replacement text"}, }, - "required": ["path", "lines_range", "safety_valve", "pattern", "context", "content"], + "required": ["path", "lines_range", "safety_valve", "pattern", "context", "content", "old_text", "new_text"], }, }, "required": ["op", "args"], @@ -66,6 +68,10 @@ Sometimes you need to grep .json files on disk, remember that all the strings inside are escaped in that case, making it a bit harder to match. +patch - Find and replace an exact string in a stored text/html/etc file (not JSON). + args: path (required), old_text (required), new_text (required) + Fails if old_text is not found exactly once. + render_download_link - Show a download card to the user for an already-stored file. The user sees a styled card with file icon, name, and download/preview button. args: path (required) @@ -76,6 +82,7 @@ mongo_store(op="save", args={"path": "investigations/abc123.json", "content": "{...json...}"}) mongo_store(op="delete", args={"path": "folder1/something_20250803.json"}) mongo_store(op="grep", args={"path": "tasks.txt", "pattern": "TODO", "context": 2}) + mongo_store(op="patch", args={"path": "report.html", "old_text": "

Old

", "new_text": "

New

"}) mongo_store(op="render_download_link", args={"path": "reports/monthly.pdf"}) """ @@ -233,6 +240,39 @@ async def handle_mongo_store( else: return f"Error: File {path} not found in MongoDB" + elif op == "patch": + if not path: + return f"Error: path parameter required for `patch` operation\n\n{HELP}" + old_text = args.get("old_text") + new_text = args.get("new_text") + if old_text is None: + return "Error: old_text is required for patch" + if new_text is None: + return "Error: new_text is required for patch" + path_error = validate_path(path) + if path_error: + return f"Error: {path_error}" + doc = await rcx.personal_mongo.find_one({"path": path}) + if not doc: + return f"Error: file not found: {path}" + if path.endswith(".json"): + return "Error: use `save` to update JSON files; `patch` is for text files only" + raw = doc.get("data") + if raw is None: + return f"Error: file has no data: {path}" + try: + content = bytes(raw).decode("utf-8") + except UnicodeDecodeError: + return "Error: file is not valid UTF-8 text" + count = content.count(old_text) + if count == 0: + return "Error: old_text not found in file" + if count > 1: + return f"Error: old_text found {count} times — make it more specific so it matches exactly once" + new_content = content.replace(old_text, new_text, 1) + await ckit_mongo.mongo_overwrite(rcx.personal_mongo, path, new_content.encode("utf-8")) + return f"✅ Patch applied to {path}" + elif op == "render_download_link": if not path: return f"Error: path parameter required for `render_download_link` operation\n\n{HELP}" From a48db8e3ca53d9add7dbcaa99d14455f63b27a4c Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Tue, 14 Apr 2026 22:52:16 +0930 Subject: [PATCH 9/9] feat(mongo): add support for patching JSON files - Remove restriction preventing patch on .json paths - For JSON files, retrieve `json` field and stringify with `json.dumps(indent=4)` - For other files, continue using `data` field decoded as UTF-8 - Update client docs to reflect broader patch support Changes apply to both backend API and client integration. --- .../integrations/fi_mongo_store.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index 73798b0b..40363e0b 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -68,7 +68,7 @@ Sometimes you need to grep .json files on disk, remember that all the strings inside are escaped in that case, making it a bit harder to match. -patch - Find and replace an exact string in a stored text/html/etc file (not JSON). +patch - Find and replace an exact string in a stored file. args: path (required), old_text (required), new_text (required) Fails if old_text is not found exactly once. @@ -256,14 +256,18 @@ async def handle_mongo_store( if not doc: return f"Error: file not found: {path}" if path.endswith(".json"): - return "Error: use `save` to update JSON files; `patch` is for text files only" - raw = doc.get("data") - if raw is None: - return f"Error: file has no data: {path}" - try: - content = bytes(raw).decode("utf-8") - except UnicodeDecodeError: - return "Error: file is not valid UTF-8 text" + json_data = doc.get("json") + if json_data is None: + return f"Error: file has no data: {path}" + content = json.dumps(json_data, indent=4) + else: + raw = doc.get("data") + if raw is None: + return f"Error: file has no data: {path}" + try: + content = bytes(raw).decode("utf-8") + except UnicodeDecodeError: + return "Error: file is not valid UTF-8 text" count = content.count(old_text) if count == 0: return "Error: old_text not found in file"