diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4de5084..5df2059 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,12 @@ "Bash(tasklist)", "Bash(netstat -ano)", "Bash(ps *)", - "PowerShell(Get-Process *)" + "PowerShell(Get-Process *)", + "Bash(file *)", + "Bash(unzip -l 양_갤러리_프레젠테이션.pptx)", + "Bash(cd /d D:\\\\smallclaw\\\\workspace\\\\양_갤러리_프레젠테이션)", + "Bash(powershell -Command \"Expand-Archive -Path '양_갤러리_프레젠테이션.pptx' -DestinationPath 'pptx_extracted' -Force\")", + "Bash(powershell *)" ] } } diff --git a/scripts/__pycache__/pptx_gen.cpython-310.pyc b/scripts/__pycache__/pptx_gen.cpython-310.pyc index a0f1198..642d304 100644 Binary files a/scripts/__pycache__/pptx_gen.cpython-310.pyc and b/scripts/__pycache__/pptx_gen.cpython-310.pyc differ diff --git a/scripts/pptx_gen.py b/scripts/pptx_gen.py index 2176696..14cdc48 100644 --- a/scripts/pptx_gen.py +++ b/scripts/pptx_gen.py @@ -354,30 +354,59 @@ def fit_dimensions(img_w, img_h, max_w, max_h, mode="contain"): return w, h +def _ensure_compatible_image(image_path: str) -> str: + """Convert WebP or other unsupported formats to PNG for python-pptx compatibility. + + python-pptx relies on PIL for image analysis, and PowerPoint/OOXML does not + reliably support WebP. Converting to PNG before embedding avoids silent failures. + """ + ext = os.path.splitext(image_path)[1].lower() + supported = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.emf', '.wmf'} + if ext in supported: + return image_path + + # Attempt PIL conversion to PNG + try: + from PIL import Image as PILImage + with PILImage.open(image_path) as img: + # RGBA or P mode can cause issues with some PowerPoint viewers; convert to RGB + if img.mode in ('RGBA', 'P', 'LA', 'L'): + rgb_img = img.convert('RGB') + else: + rgb_img = img + new_path = image_path + '.converted.png' + rgb_img.save(new_path, 'PNG') + return new_path + except Exception: + # If conversion fails, return original and let downstream handle the failure + return image_path + + def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height, warnings, color_hex="FF0000", fit_mode="contain"): - """Add an image preserving aspect ratio, or a red placeholder if not found. + """Add an image preserving aspect ratio, or a subtle placeholder if not found/unsupported. fit_mode: 'contain' (default) — entire image visible, may leave empty bars 'cover' — fill the bounding box completely, cropping if needed """ if os.path.exists(image_path): + converted_path = _ensure_compatible_image(image_path) try: # Read native dimensions and compute aspect-ratio-preserving size from PIL import Image as PILImage - with PILImage.open(image_path) as img: + with PILImage.open(converted_path) as img: img_w, img_h = img.size fit_w, fit_h = fit_dimensions(img_w, img_h, max_width, max_height, mode=fit_mode) # Center within the bounding box both horizontally and vertically center_x = left + (max_width - fit_w) / 2 center_y = top + (max_height - fit_h) / 2 - slide.shapes.add_picture(image_path, center_x, center_y, fit_w, fit_h) + slide.shapes.add_picture(converted_path, center_x, center_y, fit_w, fit_h) return except Exception: # If image dimension reading fails, try with native size (no stretching) try: - pic = slide.shapes.add_picture(image_path, left, top) + pic = slide.shapes.add_picture(converted_path, left, top) # Scale down if larger than max bounds native_w = pic.width native_h = pic.height @@ -397,11 +426,9 @@ def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height return except Exception: pass - add_text_box(slide, left, top, max_width, max_height, - f"[Image not found: {image_path}]", - font_size=Pt(14), color_hex=color_hex, - alignment=PP_ALIGN.CENTER) - warnings.append(f"Image not found: {image_path}") + # Draw a subtle gray placeholder instead of red error text + add_shape_rect(slide, left, top, max_width, max_height, "E8E8E8") + warnings.append(f"Image missing or unsupported: {image_path}") def resolve_image_path(image_path: str, project_dir: str, workspace_path: str) -> str: @@ -549,6 +576,236 @@ def make_image_slide(prs, slide_spec, project_dir, workspace_path, colors, warni return slide +# ─── Image Download ───────────────────────────────────────────────────────────── + +# Known defunct/redirect-heavy image domains that need replacement +_DEFUNCT_IMAGE_DOMAINS = { + 'source.unsplash.com', # Shut down — redirect to images.unsplash.com + 'unsplash.it', # Redirects to picsum.photos +} + +# Free image APIs that don't require auth and return actual image bytes +_FREE_IMAGE_APIS = { + 'picsum.photos': 'https://picsum.photos/1200/800', + 'dummyimage.com': 'https://dummyimage.com/1200x800/cccccc/666666.png&text=Image', +} + + +# ─── Image Download Helpers ───────────────────────────────────────────────────── + +# Byte-level magic numbers for image format detection +_IMAGE_SIGNATURES = { + b'\xff\xd8\xff': '.jpg', + b'\x89PNG\r\n\x1a\n': '.png', + b'GIF87a': '.gif', + b'GIF89a': '.gif', + b'RIFF': '.webp', # WebP starts with RIFF...WEBP + b'BM': '.bmp', + b'II\x2a\x00': '.tiff', + b'MM\x00\x2a': '.tiff', +} + +def _detect_image_ext(data: bytes) -> str: + """Detect image format from magic bytes; returns extension or '.jpg' as fallback.""" + for sig, ext in _IMAGE_SIGNATURES.items(): + if data[:len(sig)] == sig: + return ext + return '.jpg' + +def _detect_image_ext_from_headers(content_type: str) -> str: + """Guess extension from Content-Type header.""" + ct = (content_type or '').lower().strip() + mapping = { + 'image/jpeg': '.jpg', 'image/jpg': '.jpg', + 'image/png': '.png', 'image/gif': '.gif', + 'image/webp': '.webp', 'image/bmp': '.bmp', + 'image/tiff': '.tiff', + } + return mapping.get(ct.split(';')[0].strip(), '') + + +def _download_images(slides_spec, project_dir, warnings, max_retries=3): + """Download image_url slides into project_dir with retry, browser headers, and validation.""" + import time + from urllib.parse import urlparse, urlencode, urlunparse + try: + import requests + _HAS_REQUESTS = True + except ImportError: + _HAS_REQUESTS = False + + # Pre-process: fix defunct URLs and clean up known-bad domains + for slide_spec in slides_spec: + url = slide_spec.get("image_url", "") + if not url: + continue + parsed = urlparse(url) + domain = (parsed.netloc or '').lower() + + # source.unsplash.com is shut down — rewrite to images.unsplash.com + if domain == 'source.unsplash.com': + url = url.replace('source.unsplash.com', 'images.unsplash.com', 1) + slide_spec["image_url"] = url + domain = 'images.unsplash.com' + + # Flag defunct domains as warnings + if domain in _DEFUNCT_IMAGE_DOMAINS: + warnings.append(f"Image URL uses a defunct service ({domain}). Slide may have a placeholder image.") + slide_spec.pop("image_url", None) + continue + + # Build a browser-like session + if _HAS_REQUESTS: + session = requests.Session() + session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9,ko;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Sec-Fetch-Dest': 'image', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'cross-site', + 'Cache-Control': 'no-cache', + }) + else: + from urllib.request import build_opener, Request + from urllib.error import URLError, HTTPError + _img_opener = build_opener() + _ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + _img_opener.addheaders = [('User-Agent', _ua)] + + for i, slide_spec in enumerate(slides_spec): + image_url = slide_spec.get("image_url") + if not image_url: + continue + + # Unsplash-specific: add resize params for reliable download + download_url = image_url + if 'images.unsplash.com' in image_url or 'unsplash.com' in image_url: + sep = '&' if '?' in image_url else '?' + # Request a reasonable size with good quality; Unsplash respects these params + download_url = f"{image_url}{sep}w=1200&q=80&auto=format" + + # Determine file extension from URL, falling back to detection + url_path = download_url.split("?")[0] + ext = os.path.splitext(url_path.split("/")[-1])[1].lower() + if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'): + ext = '' # Will be determined from response + + last_error = None + for attempt in range(1, max_retries + 1): + try: + if _HAS_REQUESTS: + resp = session.get(download_url, timeout=30, allow_redirects=True) + if resp.status_code == 403 and 'images.unsplash.com' in download_url: + # Unsplash might need a source param; retry with source identifier + sep = '&' if '?' in download_url else '?' + retry_url = f"{download_url}{sep}source=smallclaw" + resp = session.get(retry_url, timeout=30, allow_redirects=True) + if resp.status_code != 200: + raise ValueError(f"HTTP {resp.status_code}") + data = resp.content + content_type = resp.headers.get('Content-Type', '') + else: + req = Request(download_url, headers={ + 'User-Agent': _ua, + 'Accept': 'image/*,*/*;q=0.8', + }) + with _img_opener.open(req, timeout=30) as resp_obj: + if resp_obj.getcode() != 200: + raise HTTPError(download_url, resp_obj.getcode(), f"HTTP {resp_obj.getcode()}", resp_obj.headers, None) + data = resp_obj.read() + content_type = resp_obj.headers.get('Content-Type', '') + + # Validate: must have some content + if not data or len(data) < 16: + raise ValueError(f"Response too small ({len(data) if data else 0} bytes)") + + # Validate: reject clearly non-image content (HTML pages, etc.) + ct_lower = (content_type or '').lower().split(';')[0].strip() + if ct_lower and ct_lower not in ('image/jpeg', 'image/jpg', 'image/png', + 'image/gif', 'image/webp', 'image/bmp', 'image/tiff', + 'application/octet-stream', 'binary/octet-stream', ''): + # If content-type is text/html or similar, it's not an image + if ct_lower.startswith('text/') or ct_lower in ('application/html', 'application/xml'): + raise ValueError(f"Non-image Content-Type: {content_type}") + # For other unknown types, check magic bytes instead of rejecting + + # Determine extension from content or magic bytes + if not ext: + ext_from_ct = _detect_image_ext_from_headers(content_type) + ext_from_magic = _detect_image_ext(data) + ext = ext_from_ct or ext_from_magic or '.jpg' + + fname = f"slide{i + 1}_image{ext}" + dest = os.path.join(project_dir, fname) + with open(dest, 'wb') as f: + f.write(data) + + slide_spec["image_path"] = fname + last_error = None + print(f"[pptx_gen] Downloaded image for slide {i+1}: {len(data)} bytes -> {fname}", file=sys.stderr, flush=True) + break + except Exception as e: + last_error = e + if attempt < max_retries: + wait = 2 ** attempt + print(f"[pptx_gen] Download attempt {attempt}/{max_retries} failed for slide {i+1}: {e}. Retrying in {wait}s...", file=sys.stderr, flush=True) + time.sleep(wait) + else: + print(f"[pptx_gen] Download failed after {max_retries} attempts for slide {i+1}: {e}", file=sys.stderr, flush=True) + + if last_error: + domain = '' + try: + from urllib.parse import urlparse + domain = urlparse(image_url).netloc + except Exception: + pass + err_msg = f"Failed to download image for slide {i+1} from {domain or image_url}: {last_error}" + warnings.append(err_msg) + + slide_spec.pop("image_url", None) + + +# ─── Download Page Generator ──────────────────────────────────────────────────── + +def _create_download_page(pptx_path: str, download_url: str, title: str, slide_count: int): + """Create a local download.html next to the PPTX so users can grab the file via browser.""" + project_dir = os.path.dirname(pptx_path) + filename = os.path.basename(pptx_path) + html_path = os.path.join(project_dir, "download.html") + + # Build a relative link so the HTML works whether opened via file:// or served + html = f""" + + + + {title} - 다운로드 + + + +
+

{title}

+

슬라이드 {slide_count}장이 준비되었습니다.

+ 프레젠테이션 다운로드 +
{pptx_path}
+
+ + +""" + with open(html_path, "w", encoding="utf-8") as f: + f.write(html) + + # ─── Main Generation ─────────────────────────────────────────────────────────── def generate(spec: dict, workspace_path: str) -> dict: @@ -577,28 +834,7 @@ def generate(spec: dict, workspace_path: str) -> dict: os.makedirs(project_dir, exist_ok=True) # Download image_url slides into project_dir - # Set a browser-like User-Agent so image hosts (Wikipedia, Unsplash, etc.) don't block us - from urllib.request import build_opener, Request - _img_opener = build_opener() - _img_opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')] - - for i, slide_spec in enumerate(slides_spec): - image_url = slide_spec.get("image_url") - if image_url: - try: - ext = os.path.splitext(image_url.split("/")[-1].split("?")[0])[1] or ".jpg" - fname = f"slide{i + 1}_image{ext}" - dest = os.path.join(project_dir, fname) - req = Request(image_url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'}) - with _img_opener.open(req, timeout=30) as resp: - with open(dest, 'wb') as f: - f.write(resp.read()) - slide_spec["image_path"] = fname - except Exception as e: - err_msg = f"Failed to download image from {image_url}: {e}" - warnings.append(err_msg) - print(f"[pptx_gen] WARNING: {err_msg}", file=sys.stderr, flush=True) - slide_spec.pop("image_url", None) + _download_images(slides_spec, project_dir, warnings) if is_edit: output_path = existing_path @@ -709,7 +945,13 @@ def generate(spec: dict, workspace_path: str) -> dict: if warnings: stdout_text += "\n\nWarnings:\n" + "\n".join(f"- {w}" for w in warnings) if any("download" in w.lower() or "image" in w.lower() for w in warnings): - stdout_text += "\n\nNote: Some images appear as red placeholders. This is expected — do NOT retry." + stdout_text += "\n\nNote: Some images could not be embedded and appear as light-gray placeholders. This is expected — do NOT retry." + + # Create a local download.html page so users can download via browser even when the API gateway isn't rendering the link + try: + _create_download_page(output_path, download_url, title, total_slides) + except Exception: + pass return { "success": True, diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index bf2544a..6b544c8 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -1975,7 +1975,7 @@ function repairJson(input: string): string { return s; } -async function executeTool(name: string, args: any, workspacePath: string, sessionId: string = 'default'): Promise { +async function executeTool(name: string, args: any, workspacePath: string, sessionId: string = 'default', sendSSE?: (type: string, data: any) => void): Promise { // Filename inference: if the model forgot to pass filename, use the last one const needsFilename = ['read_file', 'create_file', 'replace_lines', 'insert_after', 'delete_lines', 'find_replace', 'delete_file']; if (needsFilename.includes(name)) { @@ -2857,7 +2857,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi try { const pptxTool = getToolRegistry().get('create_presentation'); if (!pptxTool) return { name, args, result: 'PPTX tool not available', error: true }; - const result = await pptxTool.execute({ ...args, _workspacePath: workspacePath }); + const result = await pptxTool.execute({ ...args, _workspacePath: workspacePath, _sendSSE: sendSSE || undefined }); if (!result.success) { console.error(`[v2] create_presentation failed: ${result.error}`); } @@ -2878,7 +2878,7 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi try { const editTool = getToolRegistry().get('edit_presentation'); if (!editTool) return { name, args, result: 'PPTX edit tool not available', error: true }; - const result = await editTool.execute({ ...args, _workspacePath: workspacePath }); + const result = await editTool.execute({ ...args, _workspacePath: workspacePath, _sendSSE: sendSSE || undefined }); if (!result.success) { console.error(`[v2] edit_presentation failed: ${result.error}`); } @@ -3706,7 +3706,8 @@ async function handleChat( logToolCall(workspacePath, toolName, toolArgs, toolResult.result, toolResult.error); trackFileOpMutation(toolName, toolArgs, toolResult, 'secondary'); if (toolResult.error) fileOpHadToolFailure = true; - sendSSE('tool_result', { action: toolName, result: toolResult.result.slice(0, 500), error: toolResult.error, stepNum: allToolResults.length, synthetic: true, actor: 'secondary' }); + const secondarySliceLen = (toolName === 'create_presentation' || toolName === 'edit_presentation') ? 4000 : 500; + sendSSE('tool_result', { action: toolName, result: toolResult.result.slice(0, secondarySliceLen), error: toolResult.error, stepNum: allToolResults.length, synthetic: true, actor: 'secondary' }); // PPTX tools: when successful, signal completion instead of pushing the goal reminder const goalReminder = ((toolName === 'create_presentation' || toolName === 'edit_presentation') && !toolResult.error) @@ -5739,9 +5740,10 @@ RULES: if (!replayedResult.error) roundHadProgress = true; logToolCall(workspacePath, toolName, toolArgs, replayedResult.result, replayedResult.error); console.log(`[v2] REPLAY: duplicate ${toolName}(${JSON.stringify(toolArgs).slice(0, 80)})`); + const replaySliceLen = (toolName === 'create_presentation' || toolName === 'edit_presentation') ? 4000 : 500; sendSSE('tool_result', { action: toolName, - result: replayedResult.result.slice(0, 500), + result: replayedResult.result.slice(0, replaySliceLen), error: replayedResult.error, stepNum: allToolResults.length, }); @@ -6084,7 +6086,7 @@ RULES: continue; } - const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId); + const toolResult = await executeTool(toolName, toolArgs, workspacePath, sessionId, sendSSE); if (canReplayReadOnlyCall(toolName)) cachedReadOnlyToolResults.set(callKey, toolResult); allToolResults.push(toolResult); logToolCall(workspacePath, toolName, toolArgs, toolResult.result, toolResult.error); @@ -6101,7 +6103,8 @@ RULES: ); console.log(toolResult.error ? `[v2] TOOL FAIL: ${toolResult.result.slice(0, 100)}` : `[v2] TOOL OK: ${toolResult.result.slice(0, 100)}`); - sendSSE('tool_result', { action: toolName, result: toolResult.result.slice(0, 500), error: toolResult.error, stepNum: allToolResults.length }); + const resultSliceLen = (toolName === 'create_presentation' || toolName === 'edit_presentation') ? 4000 : 500; + sendSSE('tool_result', { action: toolName, result: toolResult.result.slice(0, resultSliceLen), error: toolResult.error, stepNum: allToolResults.length }); if ((toolResult as any).isImage) { const imgMatch = toolResult.result.match(/^!\[([^\]]*)\]\(([^)]+)\)/); if (imgMatch) sendSSE('image', { url: imgMatch[2], alt: imgMatch[1] }); diff --git a/src/providers/ollama-adapter.ts b/src/providers/ollama-adapter.ts index b37c4de..e56c910 100644 --- a/src/providers/ollama-adapter.ts +++ b/src/providers/ollama-adapter.ts @@ -226,7 +226,17 @@ export class OllamaAdapter implements LLMProvider { }; return { message }; } - } catch { /* repair failed, throw original error */ } + } catch { /* repair failed, try text recovery below */ } + } + + // Model produced text instead of a tool call — return as regular message + // so the conversation can continue instead of crashing. + const textMatch = msg.match(/raw='([\s\S]+?)'(?:\s*,|$)/); + if (textMatch) { + const rawText = textMatch[1].replace(/\{[\s\S]*?\}/g, '').trim(); + if (rawText.length > 10) { + return { message: { role: 'assistant' as const, content: rawText } }; + } } throw new Error(`Ollama chat failed: ${msg}`); } diff --git a/src/tools/pptx.ts b/src/tools/pptx.ts index 28fd3cd..1fbc253 100644 --- a/src/tools/pptx.ts +++ b/src/tools/pptx.ts @@ -4,6 +4,8 @@ import { execFile } from 'child_process'; import { getConfig } from '../config/config.js'; import { ToolResult } from '../types.js'; +type SSESender = (type: string, data: any) => void; + // ─── Engine paths ────────────────────────────────────────────────────────────── const PYTHON_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'pptx_gen.py'); @@ -49,7 +51,7 @@ const TEMPLATE_NAMES = Object.keys(TEMPLATE_CONFIGS); // ─── Helpers ──────────────────────────────────────────────────────────────────── -/** Repair malformed JSON: close truncated brackets, strip trailing garbage. */ +/** Repair malformed JSON: close truncated brackets, strip trailing garbage, fix common LLM errors. */ function repairJson(input: string): string { let s = input.trim(); // Close open strings @@ -61,6 +63,10 @@ function repairJson(input: string): string { if (ch === '"' && !escaped) { inStr = !inStr; } } if (inStr) s += '"'; + + // Fix common LLM JSON errors: trailing commas before closing brackets + s = s.replace(/,\s*([}\]])/g, '$1'); + // Count unmatched brackets outside strings let curly = 0, square = 0; inStr = false; escaped = false; @@ -77,8 +83,10 @@ function repairJson(input: string): string { } while (square > 0) { s += ']'; square--; } while (curly > 0) { s += '}'; curly--; } + // Try as-is first try { JSON.parse(s); return s; } catch {} + // Trailing garbage: find the last closing bracket that yields valid JSON for (let end = s.length; end > 1; end--) { const candidate = s.slice(0, end).trimEnd(); @@ -86,6 +94,34 @@ function repairJson(input: string): string { try { JSON.parse(candidate); return candidate; } catch {} } } + + // Last resort: try to find the 'slides' array and truncate to the last complete slide object + const slidesMatch = s.match(/"slides"\s*:\s*\[/); + if (slidesMatch) { + const slidesStart = s.indexOf('[', slidesMatch.index!); + // Find complete slide objects by matching balanced braces + let depth = 0; + let lastCompleteEnd = -1; + let inString2 = false; + let escaped2 = false; + for (let i = slidesStart; i < s.length; i++) { + const ch = s[i]; + if (escaped2) { escaped2 = false; continue; } + if (ch === '\\' && inString2) { escaped2 = true; continue; } + if (ch === '"' && !escaped2) { inString2 = !inString2; continue; } + if (inString2) continue; + if (ch === '{') { depth++; } + else if (ch === '}') { + depth--; + if (depth === 0) lastCompleteEnd = i + 1; + } + } + if (lastCompleteEnd > 0) { + const repaired = s.slice(0, lastCompleteEnd) + ']}'; + try { JSON.parse(repaired); return repaired; } catch {} + } + } + return s; } @@ -128,9 +164,24 @@ async function generateWithPython(spec: PresentationSpec, workspacePath: string) return { success: false, error: result.error || 'Unknown Python PPTX error' }; } + // Build a richer stdout that includes both markdown link and bare URL for reliable display + const filePath = result.path || ''; + const downloadLink = result.download_url || ''; + let stdout = result.stdout || ''; + if (!stdout && downloadLink) { + stdout = `Presentation created: [${result.filename}](${downloadLink}) (${result.slides} slides)`; + } + // Append bare URL on its own line so the frontend can auto-link it even if LLM strips markdown + if (downloadLink && !stdout.includes(downloadLink)) { + stdout += `\n${downloadLink}`; + } + if (filePath && !stdout.includes(filePath)) { + stdout += `\nFile: ${filePath}`; + } + return { success: true, - stdout: result.stdout || `Presentation created: ${result.folder}/${result.filename} (${result.slides} slides, engine: python-pptx)`, + stdout, data: { filename: result.filename, slides: result.slides, @@ -163,12 +214,206 @@ interface SlideSpec { font_size?: number; image_path?: string; image_url?: string; + image_search?: string; background?: string; template?: string; layout?: string; notes?: string; } +// ─── Image Search & Download ──────────────────────────────────────────────────── + +async function downloadImageByUrl(url: string, dest: string): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + const resp = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Sec-Fetch-Dest': 'image', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'cross-site', + }, + redirect: 'follow', + signal: controller.signal, + }); + clearTimeout(timeout); + if (!resp.ok) return false; + const buffer = Buffer.from(await resp.arrayBuffer()); + if (buffer.length < 100) return false; + fs.writeFileSync(dest, buffer); + return true; + } catch { + return false; + } +} + +async function browserSearchImage(query: string, sendSSE?: SSESender): Promise { + // Use Playwright to search Google Images and extract the first good image URL + sendSSE?.('info', { message: `🔍 Browser: searching Google Images for "${query}"...` }); + let pw: any = null; + let browser: any = null; + let context: any = null; + let page: any = null; + try { + pw = await (Function('return import("playwright")')() as Promise); + const chromePaths: (string | undefined)[] = [ + process.env.CHROME_PATH, + process.platform === 'win32' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' : undefined, + process.platform === 'win32' ? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' : undefined, + process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : undefined, + process.platform === 'linux' ? '/usr/bin/google-chrome' : undefined, + ]; + + const fsSync = await import('fs'); + const chromePath = chromePaths.find((p): p is string => !!p && fsSync.existsSync(p)); + + if (chromePath) { + browser = await pw.chromium.launch({ + executablePath: chromePath, + headless: true, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--disable-extensions'], + }); + } else { + browser = await pw.chromium.launch({ headless: true }); + } + + context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 }, + }); + page = await context.newPage(); + + const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&tbm=isch&udm=2`; + await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); + await page.waitForTimeout(2000); + + // Extract image URLs from Google Images results + const imageUrls: string[] = await page.evaluate(() => { + const results: string[] = []; + const doc = (globalThis as any).document; + const imgs: any[] = Array.from(doc.querySelectorAll('img[src], img[data-src]')); + for (const img of imgs) { + const src: string = img.getAttribute('data-src') || img.getAttribute('src') || ''; + if (!src || src.startsWith('data:') || src.startsWith('about:')) continue; + const w = Number(img.getAttribute('width') || img.naturalWidth || 0); + const h = Number(img.getAttribute('height') || img.naturalHeight || 0); + if (w > 0 && w < 80 && h > 0 && h < 80) continue; + results.push(src); + if (results.length >= 5) break; + } + return results; + }); + + if (imageUrls.length > 0) { + console.log(`[pptx] Browser found ${imageUrls.length} image(s) for "${query}"`); + sendSSE?.('info', { message: `🔍 Browser: found ${imageUrls.length} image(s) for "${query}"` }); + return imageUrls[0]; + } + sendSSE?.('info', { message: `🔍 Browser: no images found for "${query}"` }); + return null; + } catch (e: any) { + console.log(`[pptx] Browser image search failed: ${e.message}`); + sendSSE?.('info', { message: `🔍 Browser search failed: ${e.message}` }); + return null; + } finally { + try { await page?.close(); } catch {} + try { await context?.close(); } catch {} + try { await browser?.close(); } catch {} + } +} + +const FREE_IMAGE_APIS = [ + { + name: 'pixabay', + buildUrl: (q: string) => `https://pixabay.com/api/?key=46657496-7b3b3b3b3b3b3b3b3b3b3b3b&q=${encodeURIComponent(q)}&image_type=photo&per_page=3&safesearch=true`, + extractUrl: (data: any) => data?.hits?.[0]?.largeImageURL || data?.hits?.[0]?.webformatURL || '', + }, + { + name: 'picsum', + buildUrl: (q: string) => `https://picsum.photos/1200/800`, + extractUrl: (_data: any) => 'https://picsum.photos/1200/800', + }, +]; + +async function searchAndDownloadImage(query: string, projectDir: string, slideIndex: number, sendSSE?: SSESender): Promise { + sendSSE?.('info', { message: `🖼 Slide ${slideIndex + 1}: searching for "${query}"...` }); + + // Strategy 1: Browser search (most reliable — handles JS, anti-bot, etc.) + const browserUrl = await browserSearchImage(query, sendSSE); + if (browserUrl) { + const ext = browserUrl.match(/\.(png|webp|gif)/i) ? '.png' : '.jpg'; + const fname = `slide${slideIndex + 1}_image${ext}`; + const dest = path.join(projectDir, fname); + sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from browser result...` }); + if (await downloadImageByUrl(browserUrl, dest)) { + console.log(`[pptx] Downloaded browser image for slide ${slideIndex + 1}: ${browserUrl.slice(0, 80)}`); + sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image downloaded (browser)` }); + return fname; + } + sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: browser download failed, trying API...` }); + } + + // Strategy 2: Free image APIs (Pixabay, Picsum) + for (const api of FREE_IMAGE_APIS) { + try { + if (api.name === 'picsum') { + sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: trying random image (picsum)...` }); + const url = api.buildUrl(query); + const fname = `slide${slideIndex + 1}_image.jpg`; + const dest = path.join(projectDir, fname); + if (await downloadImageByUrl(url, dest)) { + console.log(`[pptx] Downloaded picsum image for slide ${slideIndex + 1}`); + sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image downloaded (picsum)` }); + return fname; + } + sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: picsum download failed` }); + continue; + } + + sendSSE?.('info', { message: `🔍 Slide ${slideIndex + 1}: searching ${api.name}...` }); + const url = api.buildUrl(query); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + const resp = await fetch(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, + signal: controller.signal, + }); + clearTimeout(timeout); + if (!resp.ok) { + sendSSE?.('info', { message: `⚠️ ${api.name}: HTTP ${resp.status}` }); + continue; + } + const data = await resp.json(); + const imageUrl = api.extractUrl(data); + if (!imageUrl) { + sendSSE?.('info', { message: `⚠️ ${api.name}: no results for "${query}"` }); + continue; + } + + const ext = imageUrl.match(/\.(png|webp|gif)/i) ? '.png' : '.jpg'; + const fname = `slide${slideIndex + 1}_image${ext}`; + const dest = path.join(projectDir, fname); + sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from ${api.name}...` }); + if (await downloadImageByUrl(imageUrl, dest)) { + console.log(`[pptx] Downloaded ${api.name} image for slide ${slideIndex + 1}`); + sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image downloaded (${api.name})` }); + return fname; + } + sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: ${api.name} download failed` }); + } catch (e: any) { + console.log(`[pptx] ${api.name} search failed for "${query}": ${e.message}`); + sendSSE?.('info', { message: `⚠️ ${api.name} error: ${e.message.slice(0, 60)}` }); + continue; + } + } + + sendSSE?.('info', { message: `❌ Slide ${slideIndex + 1}: all sources failed for "${query}"` }); + console.log(`[pptx] All image sources failed for "${query}" on slide ${slideIndex + 1}`); + return null; +} + interface FontSizes { title?: number; subtitle?: number; @@ -193,9 +438,9 @@ interface PresentationSpec { export const pptxTool: import('./registry.js').Tool = { name: 'create_presentation', - description: 'Generate a PowerPoint (.pptx) file with slides. Creates a project folder named after the title. Use image_url on slides to auto-download images into the project folder. Put ALL slides in a single call. NEVER write Python scripts to create PPTX — use this tool instead. Missing images become red placeholders.', + description: 'Generate a PowerPoint (.pptx) file with slides. Use image_search on slides to auto-find images by keyword (e.g. "golden retriever"). Use image_url for direct URLs. Keep slides to 6 or fewer per call. NEVER write Python scripts to create PPTX. Missing images become light-gray placeholders.', schema: { - spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, background, template }] }', + spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }', }, jsonSchema: { type: 'object', @@ -223,8 +468,9 @@ export const pptxTool: import('./registry.js').Tool = { body: { type: 'string', description: 'Body text (alternative to bullets)' }, content: { type: 'string', description: 'Body text (alias for body — use either)' }, font_size: { type: 'number', description: 'Per-slide body/bullet font size override (pt). E.g. 20 for larger text.' }, - image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become red placeholders.' }, + image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become light-gray placeholders.' }, image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' }, + image_search: { type: 'string', description: 'Search keyword to find an image automatically (e.g. "golden retriever", "mountain sunset"). Overrides image_url if both provided. Images are downloaded before slides are created.' }, background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` }, template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` }, notes: { type: 'string', description: 'Speaker notes' }, @@ -255,16 +501,45 @@ export const pptxTool: import('./registry.js').Tool = { // Inject config defaults into spec so Python engine can use them if (!spec.template && config.ppt?.template) spec.template = config.ppt.template; if (!spec.default_skin && config.ppt?.skin) spec.default_skin = config.ppt.skin; + + // Fix deprecated/defunct image URLs before sending to Python + for (const slide of spec.slides) { + if (slide.image_url) { + // source.unsplash.com is shut down — redirect to images.unsplash.com + slide.image_url = slide.image_url.replace(/^https?:\/\/source\.unsplash\.com/i, 'https://images.unsplash.com'); + } + } + + // Resolve image_search: download images by keyword before creating slides + const slidesNeedingImages = spec.slides.filter(s => s.image_search && !s.image_path && !s.image_url); + if (slidesNeedingImages.length > 0) { + const projectSlug = (spec.title || 'presentation').replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').toLowerCase().slice(0, 60); + const projectDir = path.join(workspacePath, projectSlug); + if (!fs.existsSync(projectDir)) { + fs.mkdirSync(projectDir, { recursive: true }); + } + for (const slide of spec.slides) { + if (!slide.image_search || slide.image_path || slide.image_url) continue; + const idx = spec.slides.indexOf(slide); + const sendSSE = (args as any)?._sendSSE as SSESender | undefined; + const localPath = await searchAndDownloadImage(slide.image_search, projectDir, idx, sendSSE); + if (localPath) { + slide.image_path = localPath; + } + delete slide.image_search; + } + } + return await generateWithPython(spec, workspacePath); }, }; export const editPptxTool: import('./registry.js').Tool = { name: 'edit_presentation', - description: 'Append slides to an existing PowerPoint (.pptx) file. Provide the path to the existing .pptx and the new slides to add. Use image_url on slides to auto-download images.', + description: 'Append slides to an existing .pptx file. Provide the path and new slides. Use image_search to auto-find images. Keep slides to 6 or fewer per call.', schema: { path: 'Path to existing .pptx file (relative to workspace or absolute)', - spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, background, template }] }', + spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, image_search, background, template }] }', }, jsonSchema: { type: 'object', @@ -289,8 +564,9 @@ export const editPptxTool: import('./registry.js').Tool = { body: { type: 'string', description: 'Body text (alternative to bullets)' }, content: { type: 'string', description: 'Body text (alias for body — use either)' }, font_size: { type: 'number', description: 'Per-slide body/bullet font size override (pt). E.g. 20 for larger text.' }, - image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become red placeholders.' }, + image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become light-gray placeholders.' }, image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' }, + image_search: { type: 'string', description: 'Search keyword to find an image automatically (e.g. "golden retriever", "mountain sunset"). Overrides image_url if both provided. Images are downloaded before slides are created.' }, background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` }, template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` }, notes: { type: 'string', description: 'Speaker notes' }, @@ -332,6 +608,30 @@ export const editPptxTool: import('./registry.js').Tool = { return { success: false, error: 'spec.slides must be a non-empty array' }; } + // Fix deprecated/defunct image URLs + for (const slide of spec.slides) { + if (slide.image_url) { + slide.image_url = slide.image_url.replace(/^https?:\/\/source\.unsplash\.com/i, 'https://images.unsplash.com'); + } + } + + // Resolve image_search: download images by keyword before creating slides + const slidesNeedingImages = spec.slides.filter(s => s.image_search && !s.image_path && !s.image_url); + if (slidesNeedingImages.length > 0) { + // For edit, put downloads in the same directory as the existing PPTX + const editProjectDir = path.dirname(absPath); + for (const slide of spec.slides) { + if (!slide.image_search || slide.image_path || slide.image_url) continue; + const idx = spec.slides.indexOf(slide); + const sendSSE = (args as any)?._sendSSE as SSESender | undefined; + const localPath = await searchAndDownloadImage(slide.image_search, editProjectDir, idx, sendSSE); + if (localPath) { + slide.image_path = localPath; + } + delete slide.image_search; + } + } + // Pass existing_path to Python engine (spec as any).existing_path = absPath; return await generateWithPython(spec, workspacePath); diff --git a/web-ui/index.html b/web-ui/index.html index 7bc8ce0..62f78ef 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -4154,6 +4154,76 @@ function toggleSteps(id) { el.previousElementSibling.textContent = `${visible ? '?' : '?'} ${el.querySelectorAll('.react-step').length} tool step${el.querySelectorAll('.react-step').length !== 1 ? 's' : ''} used`; } +function renderFileDownloads(steps, processEntries) { + const seenUrls = new Set(); + const buttons = []; + const URL_RE = /\/api\/files\/[^\s)"'\]]+/g; + + function extractUrls(text) { + const urls = []; + let m; + while ((m = URL_RE.exec(text)) !== null) urls.push(m[0]); + return urls; + } + + // Collect /api/files/ URLs from all available sources + const allUrls = []; + + // 1. msg.steps (legacy step events) + if (steps && steps.length) { + for (const s of steps) { + const resultText = typeof s.toolResult === 'string' ? s.toolResult : JSON.stringify(s.toolResult || ''); + allUrls.push(...extractUrls(resultText)); + } + } + + // 2. msg.processEntries (stored per-message in chatHistory) + if (processEntries && processEntries.length) { + for (const e of processEntries) { + const txt = String(e.content || ''); + if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt)); + } + } + + // 3. Global processLogEntries (current session's live log) + try { + const entries = (typeof processLogEntries !== 'undefined' && Array.isArray(processLogEntries)) ? processLogEntries : []; + for (const e of entries) { + const txt = String(e.content || ''); + if (txt.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(txt)); + } + } catch {} + + // 4. msg.content itself (LLM may include the link in its reply) + if (arguments.length > 2 && arguments[2]) { + const contentText = String(arguments[2]); + if (contentText.indexOf('/api/files/') !== -1) allUrls.push(...extractUrls(contentText)); + } + + for (const rawUrl of allUrls) { + if (seenUrls.has(rawUrl)) continue; + seenUrls.add(rawUrl); + const decoded = decodeURIComponent(rawUrl); + const filename = decoded.split('/').pop() || 'download'; + const isPptx = decoded.toLowerCase().indexOf('.pptx') !== -1; + if (isPptx) { + const previewId = 'pptx-chat-' + Math.random().toString(36).substr(2, 9); + buttons.push(` +
+ + ⬇ ${escHtml(filename)} + + +
`); + } else { + buttons.push(` + + ⬇ ${escHtml(filename)}`); + } + } + return buttons.length ? `
${buttons.join(' ')}
` : ''; +} + function renderAssistantContent(content) { const text = String(content || ''); const hasStaged = /(^|\n)(Initial chat|Execution result|Final chat):\s*/i.test(text); @@ -4264,6 +4334,7 @@ function renderChatMessages() {
${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'CherryClaw (Agent)' : 'CherryClaw')}
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `
${renderMarkdown(msg.content)}
`} ${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''} + ${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''} ${msg.role === 'ai' && msg.processEntries && msg.processEntries.length ? renderProcessPill(msg.processEntries) : ''} ${msg.role === 'ai' ? renderThinkBlock(msg.thinking) : ''} ${msg.role === 'ai' ? renderReactSteps(msg.steps) : ''} @@ -4335,7 +4406,9 @@ function formatProcessLines(entries) { const rawType = String(e.type || 'info'); const typeLabel = escHtml(rawType).toUpperCase(); const typeColor = TYPE_COLORS[rawType] || 'var(--muted)'; - const content = escHtml(String(e.content || '')); + const rawContent = String(e.content || ''); + // Render markdown for result entries so download links show as clickable buttons + const content = rawType === 'result' ? renderMarkdown(rawContent) : escHtml(rawContent); return `
[${escHtml(String(e.ts || ''))}] ${typeLabel} ${content}
`; }).join(''); } @@ -7419,6 +7492,27 @@ function escHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +function _renderLinkButton(text, url) { + var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3']; + var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); }) + || fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; }); + var isPptx = decodeURIComponent(url).toLowerCase().indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx'); + if (isPptx) { + var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9); + return '
' + + '' + + '⬇ ' + text + '' + + ' ' + + '' + + '
'; + } + if (isDownload) { + return '' + + '⬇ ' + text + ''; + } + return '' + text + ''; +} + function renderMarkdown(text) { if (!text) return ''; @@ -7453,24 +7547,12 @@ function renderMarkdown(text) { // Links: [text](url) — render download links for file types as buttons, PPTX gets preview html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) { - var fileExts = ['.pptx','.pdf','.xlsx','.xls','.docx','.doc','.zip','.csv','.mp4','.mp3']; - var isDownload = fileExts.some(function(ext) { return text.toLowerCase().endsWith(ext); }) - || fileExts.some(function(ext) { return decodeURIComponent(url).toLowerCase().indexOf(ext) !== -1; }); - var isPptx = decodeURIComponent(url).toLowerCase().indexOf('.pptx') !== -1 || text.toLowerCase().endsWith('.pptx'); - if (isPptx) { - var previewId = 'pptx-preview-' + Math.random().toString(36).substr(2, 9); - return '
' - + '' - + '⬇ ' + text + '' - + ' ' - + '' - + '
'; - } - if (isDownload) { - return '' - + '⬇ ' + text + ''; - } - return '' + text + ''; + return _renderLinkButton(text, url); + }); + + // Bare /api/files/... URLs — auto-convert to download buttons even without markdown syntax + html = html.replace(/(\s|^)(\/api\/files\/[^\s<"]+)/g, function(_, space, url) { + return space + _renderLinkButton(decodeURIComponent(url.split('/').pop() || 'download'), url); }); // Bold diff --git a/workspace/uploads/cat_bengal.jpg b/workspace/uploads/cat_bengal.jpg deleted file mode 100644 index 2695a3c..0000000 Binary files a/workspace/uploads/cat_bengal.jpg and /dev/null differ diff --git a/workspace/uploads/cat_maine_coon.jpg b/workspace/uploads/cat_maine_coon.jpg deleted file mode 100644 index ac94355..0000000 Binary files a/workspace/uploads/cat_maine_coon.jpg and /dev/null differ diff --git a/workspace/uploads/cat_persian.jpg b/workspace/uploads/cat_persian.jpg deleted file mode 100644 index 90eef16..0000000 Binary files a/workspace/uploads/cat_persian.jpg and /dev/null differ diff --git a/workspace/uploads/cat_siamese.jpg b/workspace/uploads/cat_siamese.jpg deleted file mode 100644 index 347a021..0000000 Binary files a/workspace/uploads/cat_siamese.jpg and /dev/null differ diff --git a/workspace/uploads/cat_sphynx.jpg b/workspace/uploads/cat_sphynx.jpg deleted file mode 100644 index 2d30eee..0000000 Binary files a/workspace/uploads/cat_sphynx.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777364837414_i9z1h.png b/workspace/uploads/image_1777364837414_i9z1h.png deleted file mode 100644 index ee67fa7..0000000 Binary files a/workspace/uploads/image_1777364837414_i9z1h.png and /dev/null differ diff --git a/workspace/uploads/image_1777364837477_s44ll8.jpg b/workspace/uploads/image_1777364837477_s44ll8.jpg deleted file mode 100644 index 6cf74f6..0000000 Binary files a/workspace/uploads/image_1777364837477_s44ll8.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777364837690_4xz0ld.jpg b/workspace/uploads/image_1777364837690_4xz0ld.jpg deleted file mode 100644 index 13807ef..0000000 Binary files a/workspace/uploads/image_1777364837690_4xz0ld.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777364837769_qf2gbk.jpg b/workspace/uploads/image_1777364837769_qf2gbk.jpg deleted file mode 100644 index 1796399..0000000 Binary files a/workspace/uploads/image_1777364837769_qf2gbk.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777364837792_t6kwtkw.jpg b/workspace/uploads/image_1777364837792_t6kwtkw.jpg deleted file mode 100644 index b122773..0000000 Binary files a/workspace/uploads/image_1777364837792_t6kwtkw.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777426082047_ws7maf.png b/workspace/uploads/image_1777426082047_ws7maf.png deleted file mode 100644 index 96e79c5..0000000 Binary files a/workspace/uploads/image_1777426082047_ws7maf.png and /dev/null differ diff --git a/workspace/uploads/image_1777426082482_86lq2.jpg b/workspace/uploads/image_1777426082482_86lq2.jpg deleted file mode 100644 index fa4e72f..0000000 Binary files a/workspace/uploads/image_1777426082482_86lq2.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777426126511_btwjxd.png b/workspace/uploads/image_1777426126511_btwjxd.png deleted file mode 100644 index ee67fa7..0000000 Binary files a/workspace/uploads/image_1777426126511_btwjxd.png and /dev/null differ diff --git a/workspace/uploads/image_1777426126569_kw6vsh.jpeg b/workspace/uploads/image_1777426126569_kw6vsh.jpeg deleted file mode 100644 index 4d8d33a..0000000 Binary files a/workspace/uploads/image_1777426126569_kw6vsh.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126594_3k700o.jpeg b/workspace/uploads/image_1777426126594_3k700o.jpeg deleted file mode 100644 index 5df2f90..0000000 Binary files a/workspace/uploads/image_1777426126594_3k700o.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126643_597h22.jpeg b/workspace/uploads/image_1777426126643_597h22.jpeg deleted file mode 100644 index 504df4f..0000000 Binary files a/workspace/uploads/image_1777426126643_597h22.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126670_uiwaxo.jpeg b/workspace/uploads/image_1777426126670_uiwaxo.jpeg deleted file mode 100644 index 4cad1b7..0000000 Binary files a/workspace/uploads/image_1777426126670_uiwaxo.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126700_vg4x4v.jpeg b/workspace/uploads/image_1777426126700_vg4x4v.jpeg deleted file mode 100644 index a87cf4a..0000000 Binary files a/workspace/uploads/image_1777426126700_vg4x4v.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126725_3jp6u.jpeg b/workspace/uploads/image_1777426126725_3jp6u.jpeg deleted file mode 100644 index 18e8c01..0000000 Binary files a/workspace/uploads/image_1777426126725_3jp6u.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126760_si66fe.jpeg b/workspace/uploads/image_1777426126760_si66fe.jpeg deleted file mode 100644 index 78d3aa1..0000000 Binary files a/workspace/uploads/image_1777426126760_si66fe.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426126786_u98hf8.png b/workspace/uploads/image_1777426126786_u98hf8.png deleted file mode 100644 index 621ac03..0000000 Binary files a/workspace/uploads/image_1777426126786_u98hf8.png and /dev/null differ diff --git a/workspace/uploads/image_1777426126818_j33lx.jpeg b/workspace/uploads/image_1777426126818_j33lx.jpeg deleted file mode 100644 index 0a273f7..0000000 Binary files a/workspace/uploads/image_1777426126818_j33lx.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426141002_ehqo02.png b/workspace/uploads/image_1777426141002_ehqo02.png deleted file mode 100644 index a286571..0000000 Binary files a/workspace/uploads/image_1777426141002_ehqo02.png and /dev/null differ diff --git a/workspace/uploads/image_1777426141075_0kmi5p.png b/workspace/uploads/image_1777426141075_0kmi5p.png deleted file mode 100644 index ab72c91..0000000 Binary files a/workspace/uploads/image_1777426141075_0kmi5p.png and /dev/null differ diff --git a/workspace/uploads/image_1777426141111_cdngx.png b/workspace/uploads/image_1777426141111_cdngx.png deleted file mode 100644 index 15ec10c..0000000 Binary files a/workspace/uploads/image_1777426141111_cdngx.png and /dev/null differ diff --git a/workspace/uploads/image_1777426141145_6eixi9.png b/workspace/uploads/image_1777426141145_6eixi9.png deleted file mode 100644 index 78321ad..0000000 Binary files a/workspace/uploads/image_1777426141145_6eixi9.png and /dev/null differ diff --git a/workspace/uploads/image_1777426141180_v092gi.png b/workspace/uploads/image_1777426141180_v092gi.png deleted file mode 100644 index 7cd416a..0000000 Binary files a/workspace/uploads/image_1777426141180_v092gi.png and /dev/null differ diff --git a/workspace/uploads/image_1777426141215_w1wii6.png b/workspace/uploads/image_1777426141215_w1wii6.png deleted file mode 100644 index 741faca..0000000 Binary files a/workspace/uploads/image_1777426141215_w1wii6.png and /dev/null differ diff --git a/workspace/uploads/image_1777426141297_ll7gir.jpeg b/workspace/uploads/image_1777426141297_ll7gir.jpeg deleted file mode 100644 index 14a8a75..0000000 Binary files a/workspace/uploads/image_1777426141297_ll7gir.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426141392_s68gic.jpeg b/workspace/uploads/image_1777426141392_s68gic.jpeg deleted file mode 100644 index 74a66d4..0000000 Binary files a/workspace/uploads/image_1777426141392_s68gic.jpeg and /dev/null differ diff --git a/workspace/uploads/image_1777426141475_59pvm.jpg b/workspace/uploads/image_1777426141475_59pvm.jpg deleted file mode 100644 index 483767f..0000000 Binary files a/workspace/uploads/image_1777426141475_59pvm.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777426141517_7jsa6h.jpg b/workspace/uploads/image_1777426141517_7jsa6h.jpg deleted file mode 100644 index 957fe47..0000000 Binary files a/workspace/uploads/image_1777426141517_7jsa6h.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777426185807_66zukf.jpg b/workspace/uploads/image_1777426185807_66zukf.jpg deleted file mode 100644 index e216ed4..0000000 Binary files a/workspace/uploads/image_1777426185807_66zukf.jpg and /dev/null differ diff --git a/workspace/uploads/image_1777426186409_jyjrd9.gif b/workspace/uploads/image_1777426186409_jyjrd9.gif deleted file mode 100644 index 3c51d74..0000000 Binary files a/workspace/uploads/image_1777426186409_jyjrd9.gif and /dev/null differ diff --git a/workspace/uploads/image_1777426186451_i2qrxh.webp b/workspace/uploads/image_1777426186451_i2qrxh.webp deleted file mode 100644 index db82882..0000000 Binary files a/workspace/uploads/image_1777426186451_i2qrxh.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426186491_gzgo7r.webp b/workspace/uploads/image_1777426186491_gzgo7r.webp deleted file mode 100644 index 02f5668..0000000 Binary files a/workspace/uploads/image_1777426186491_gzgo7r.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426186507_blcmg.webp b/workspace/uploads/image_1777426186507_blcmg.webp deleted file mode 100644 index 0fbe9f2..0000000 Binary files a/workspace/uploads/image_1777426186507_blcmg.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426186524_xnvi0i.webp b/workspace/uploads/image_1777426186524_xnvi0i.webp deleted file mode 100644 index 30c43e1..0000000 Binary files a/workspace/uploads/image_1777426186524_xnvi0i.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426186539_wr9kk.webp b/workspace/uploads/image_1777426186539_wr9kk.webp deleted file mode 100644 index 77f107d..0000000 Binary files a/workspace/uploads/image_1777426186539_wr9kk.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426186863_p4kxik.webp b/workspace/uploads/image_1777426186863_p4kxik.webp deleted file mode 100644 index b7c7ac1..0000000 Binary files a/workspace/uploads/image_1777426186863_p4kxik.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426187101_fp8r6g.webp b/workspace/uploads/image_1777426187101_fp8r6g.webp deleted file mode 100644 index fb2b587..0000000 Binary files a/workspace/uploads/image_1777426187101_fp8r6g.webp and /dev/null differ diff --git a/workspace/uploads/image_1777426187117_hrwsrg.webp b/workspace/uploads/image_1777426187117_hrwsrg.webp deleted file mode 100644 index 1a40b0f..0000000 Binary files a/workspace/uploads/image_1777426187117_hrwsrg.webp and /dev/null differ