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(`
+ `);
+ } 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 '';
+ }
+ 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 '';
- }
- 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