v4.1.6: SMILES 구조식 위젯 교과서식 개편 + 화학표기 규칙 추가 + 코드블록 렌더링 버그 수정

- SMILES 위젯을 버튼 클릭식 큰 위젯 → 자동렌더링 소형 인라인(교과서식)으로 전면 개편
- 링크/이미지/볼드 등 후속 정규식이 SMILES 입체화학 표기([C@@H](C) 등)를 마크다운 링크로
  오인해 구조식이 깨지던 버그 수정 (코드블록 결과물을 플레이스홀더로 보호 후 최종 복원)
- 구조 복잡도(중원자 수)에 따라 SMILES 캔버스 크기 동적 조절 (벤젠은 작게, 콜레스테롤 등은 크게)
- SMILES 위젯 테두리/배경/여백 제거로 더 가벼운 인라인 표시
- 코드블록(.md-code-block) 검은 배경 제거 + 고정폭 폰트 누락으로 ASCII 다이어그램이
  틀어지던 버그 수정 (font-family 미지정 → 본문 가변폭 폰트 상속이 원인)
- code.trim()이 전체 문자열 기준으로만 동작해 ASCII 아트 첫 줄의 들여쓰기만 사라지던
  버그 수정 (줄 단위가 아닌 blank 줄만 제거하도록 변경)
- 시스템 프롬프트에 CHEMISTRY NOTATION 규칙 추가 (ASCII 아트 금지, LaTeX/mhchem·SMILES 사용 유도)
- Ollama Cloud 일시적 오류(5xx/타임아웃) 발생 시 즉시 실패 대신 백오프 재시도 추가
- weather_map_screenshot 툴을 ToolRegistry에도 등록 (buildImageMarkdown export 추가)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-14 15:18:23 +09:00
co-authored by Claude Sonnet 5
parent c8b4e251bf
commit 2f690fd999
6 changed files with 100 additions and 49 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
"version": "4.1.5",
"version": "4.1.6",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
+18
View File
@@ -5785,6 +5785,7 @@ async function handleChat(
content: isTranslateSession ? `You are a medical translator. Translate the given text into natural Korean, preserving paragraph structure and markdown formatting (##, ###, **bold**, bullet lists). Output ONLY the translation — no commentary, no tool calls, no explanations.` : isProjSession ? `You are a project file designer. Output ONLY the project-files JSON block as instructed. No tool calls. No extra text.` : `${executionModeSystemBlock ? `${executionModeSystemBlock}\n\n` : ''}You are SmallClaw 🦞, a local AI assistant.\nCurrent date: ${dateStr}, ${timeStr}.\nNever search for or link SmallClaw repos unless the user is asking about SmallClaw itself.\nThis app runs on the user's own machine — browser/desktop automation requests are pre-authorized.\nKeep responses SHORT (1-2 sentences). Don't think out loud. Act and report. Greet naturally without tools.
ANTI-HALLUCINATION: When a tool returns a result, report EXACTLY what the tool returned — never contradict or ignore tool output. If a tool says "(no rows)", say so. Never invent data, file contents, table names, or command output. If you don't know something, call a tool to find out or say you don't know.
IMAGE EDITING RULE: NEVER call image_edit (or any editing tool) when a user uploads a photo without explicitly requesting edits. Uploading a photo is NOT a request to edit it. Only call image_edit when the user's message explicitly asks for an edit (e.g. "수채화로 바꿔줘", "회전해줘"). Violating this rule is a critical error.${browserRuleBlock}
CHEMISTRY NOTATION: NEVER draw molecular structures as ASCII art (H/C/#/=/\\/| characters arranged to look like a diagram) — it always renders as garbled, misaligned text. Instead: for a formula or reaction, use LaTeX inside $...$ (e.g. $\\ce{C4H10}$, $\\ce{2H2 + O2 -> 2H2O}$ — mhchem extension is loaded). For an actual 2D structure with real bond lines (rings, branches), output a \`\`\`smiles\`\`\` code block containing the SMILES string (e.g. \`\`\`smiles\\nc1ccccc1\\n\`\`\` for benzene, \`\`\`smiles\\nCC(=O)Oc1ccccc1C(=O)O\\n\`\`\` for aspirin) — the client automatically renders it as a proper 2D diagram with bond lines.
CODE OUTPUT: When writing code in a fenced code block, start with a filename comment on line 1: \`# filename: snake_game.py\` (Python), \`// filename: app.js\` (JS/C), \`<!-- filename: index.html -->\` (HTML). Never repeat code already written in this conversation. For modifications to existing files, use coder_overwrite_lines or coder_insert_lines (not coder_write_file — it only works for NEW files). All code changes are presented as diffs for the user to review before being applied. Write code directly — do not ask for permission.
PACKAGE INSTALL: NEVER run pip install, npm install, apt-get, or any package installation command. If a package is missing, just write the code and mention the package name in a comment — let the user decide whether to install it. Do NOT attempt to install packages yourself.
OUTPUT FORMAT: When presenting 3+ items (news articles, emails, search results, lists), always use a markdown table or structured bullet list with clear headers. Never dump them as a long paragraph. Example: news → table with columns 제목|요약|출처.${callerContext ? '\n\n' + callerContext : ''}${browserStateCtx}${personalityCtx}${skillsManager.buildPromptContextForUser(username ? getUserWorkspace(username) : null, 16000, message)}`,
@@ -6767,6 +6768,16 @@ RULES:
sendSSE('info', { message: 'Thinking...' });
// Transient cloud-provider errors (Ollama Cloud 500s, timeouts, connection resets) are
// currently surfaced straight to the user as `Error: ...` with zero retry — a brief
// provider-side blip becomes the entire assistant reply. Retry a couple of times with a
// short backoff before giving up; a separate counter from MAX_TOOL_ROUNDS so retries
// don't eat into the model's legitimate tool-calling budget.
let transientErrorRetryCount = 0;
const MAX_TRANSIENT_ERROR_RETRIES = 2;
const isTransientProviderError = (msg: string): boolean =>
/internal server error|bad gateway|service unavailable|gateway timeout|\b5\d{2}\b|econnreset|etimedout|socket hang up|fetch failed/i.test(String(msg || ''));
for (let round = 0; ; round++) {
if (round >= MAX_TOOL_ROUNDS) {
const allowExtendedFileOpLoop =
@@ -7184,6 +7195,13 @@ RULES:
}
} catch (err: any) {
console.error('[v2] Chat error:', err.message);
if (isTransientProviderError(err.message) && transientErrorRetryCount < MAX_TRANSIENT_ERROR_RETRIES) {
transientErrorRetryCount++;
const backoffMs = 1500 * transientErrorRetryCount;
sendSSE('info', { message: `Provider error, retrying (${transientErrorRetryCount}/${MAX_TRANSIENT_ERROR_RETRIES})...` });
await new Promise(r => setTimeout(r, backoffMs));
continue;
}
return { type: 'chat', text: `Error: ${err.message}` };
}
+1 -1
View File
@@ -30,7 +30,7 @@ function runPython(script: string, timeoutMs = 60_000): Promise<any> {
});
}
function buildImageMarkdown(filePath: string, workspacePath: string): string {
export function buildImageMarkdown(filePath: string, workspacePath: string): string {
const relPath = path.relative(workspacePath, filePath).replace(/\\/g, '/');
const baseName = path.basename(filePath);
return `![${baseName}](/api/files/${relPath})`;
+2 -1
View File
@@ -20,7 +20,7 @@ import { imageReadTool, imagePreviewTool, imageInfoTool, imageEditTool } from '.
import { audioTranscribeTool } from './audio-transcribe.js';
import { pythonEvalTool } from './python.js';
import { sqliteTool } from './sqlite.js';
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool } from './weather.js';
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.js';
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
import { kakaoSendTool } from './kakao.js';
import { koreanLawSearchTool, koreanLawFetchTool, usCaseSearchTool } from './legal.js';
@@ -217,6 +217,7 @@ class ToolRegistry {
this.registerSafe(weatherEra5Tool);
this.registerSafe(weatherCdsTool);
this.registerSafe(weatherCmip6Tool);
this.registerSafe(weatherMapScreenshotTool);
// Email tools (IMAP + SMTP)
this.registerSafe(emailListTool);
this.registerSafe(emailReadTool);
+66 -44
View File
@@ -1136,6 +1136,7 @@ function renderChatMessages() {
validateDownloadLinks(container);
renderKatexIn(container);
renderSmilesIn(container);
if (isThinking && streamingSessionId === activeChatSessionId) {
const progressHtml = currentProgressLines.length
@@ -6269,8 +6270,19 @@ function renderMarkdown(text, opts) {
// &#96; (it escapes backtick/backslash/$ too, not just &<>"'), so matching on literal
// backticks here never matched anything — every special widget block (midi-widget, abc,
// lilypond, tradingview, kstock, smiles) silently fell through as escaped plain text.
// Rendered widget HTML (smiles data-smiles attrs especially) can itself contain raw
// "[X](Y)"-shaped substrings (SMILES stereo notation like [C@@H](C) reads exactly like
// markdown link syntax) that the later link/image/bold regexes would then mangle mid-
// attribute. Stash each block's final HTML behind a placeholder and restore it after
// all other text transforms have run, the same trick used for <img> above.
var codeBlockPlaceholders = [];
html = html.replace(/&#96;&#96;&#96;([\w-]*)\n([\s\S]*?)&#96;&#96;&#96;/g, function(_, lang, code) {
var trimmed = code.trim();
var result = (function() {
// code.trim() would strip leading whitespace off only the FIRST line (it operates on
// the whole string, not per-line), misaligning ASCII-art whose top row is indented
// (e.g. a right-shifted "O" over a left-aligned structure below it). Only drop fully
// blank leading/trailing lines so every line's own indentation survives intact.
var trimmed = code.replace(/^\n+/, '').replace(/\n+$/, '');
if (lang === 'midi-widget') {
try {
var mwRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'");
@@ -6362,23 +6374,20 @@ function renderMarkdown(text, opts) {
// backslash (e.g. C/C=C/C), which would otherwise stay mangled as &#92;.
var smRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'").replace(/&#96;/g,'`').replace(/&#36;/g,'$').replace(/&#92;/g,'\\').trim();
if (!smRaw) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
// Textbook-style: no header/button chrome, auto-rendered by renderSmilesIn() right
// after insertion (mirrors renderKatexIn — innerHTML doesn't run embedded <script>,
// so *something* has to trigger the draw call after the SVG lands in the DOM).
var smId = 'sm-' + Math.random().toString(36).slice(2, 9);
var smEncoded = encodeURIComponent(smRaw);
return '<div class="music-widget" id="' + smId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">⚗️ ' + escHtml(smRaw) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderSmilesStructure(this,\'' + smId + '-c\',decodeURIComponent(\'' + smEncoded + '\'))">구조식 그리기</button>' +
'<button onclick="toggleMusicWidget(\'' + smId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + smId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body" style="padding:8px;text-align:center">' +
'<svg id="' + smId + '-c" width="400" height="300" style="max-width:100%"></svg>' +
'</div>' +
'</div>';
var smSize = smilesSizeFor(smRaw);
return '<span class="smiles-inline" data-smiles="' + escHtml(smRaw) + '">' +
'<svg id="' + smId + '" width="' + smSize.width + '" height="' + smSize.height + '"></svg>' +
'</span>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
})();
var phIdx = codeBlockPlaceholders.length;
codeBlockPlaceholders.push(result);
return '\x00CODEBLK' + phIdx + '\x00';
});
// Inline code — same escHtml()-escapes-backticks issue as the fenced code blocks above.
@@ -6495,6 +6504,11 @@ function renderMarkdown(text, opts) {
html = out.join('\n');
})();
// Restore widget HTML stashed before the link/image/bold/table passes above.
codeBlockPlaceholders.forEach(function(blockHtml, i) {
html = html.replace('\x00CODEBLK' + i + '\x00', function() { return blockHtml; });
});
// Line breaks
html = html.replace(/\n/g, '<br>');
@@ -6540,39 +6554,47 @@ function renderTVChart(btn, containerId, symbol, interval) {
}
}
// Fixed 180x130 was tuned for benzene-sized rings and left bigger molecules (e.g.
// cholesterol's 4 fused rings + side chain) cramped/illegible. Scale the canvas with a
// rough heavy-atom count from the raw SMILES so the figure stays roomy either way.
function smilesSizeFor(smiles) {
var atomCount = (smiles.match(/Cl|Br|\[[^\]]+\]|[BCNOFPSIbcnops]/g) || []).length;
var extra = Math.max(0, atomCount - 6);
var width = Math.min(420, 180 + extra * 8);
var height = Math.min(320, 130 + extra * 6);
return { width: width, height: height };
}
// SMILES → 2D 화학 구조식 (결합선 포함). SmilesDrawer는 로컬 캔버스 렌더링이라
// 네트워크 지연 없이 즉시 그려지지만, innerHTML로 삽입된 <canvas>는 아직 DOM에
// 붙기 전이라 위젯 코드 블록에서 바로 그릴 수 없음 — 버튼 클릭으로 트리거.
function renderSmilesStructure(btn, svgId, smiles) {
btn.disabled = true;
btn.textContent = '그리는 중...';
// Auto-renders every not-yet-rendered .smiles-inline in `container` — mirrors
// renderKatexIn(): innerHTML doesn't execute embedded <script>, so something has to
// walk the freshly-inserted DOM and trigger the actual draw call. Small textbook-style
// figure, no header/button chrome (see the 'smiles' code-block case in renderMarkdown).
function renderSmilesIn(container) {
if (!window.SmilesDrawer || !container) return;
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
if (!window.SmilesDrawer) {
btn.disabled = false;
btn.textContent = '⚠️ 라이브러리 로드 실패';
return;
}
try {
SmilesDrawer.parse(smiles, function(tree) {
try {
// SmilesDrawer.Drawer (canvas) throws internally in this build ("this.svgWrapper
// is null" — a bug in the npm package itself, reproduced directly against the
// unminified call). SvgDrawer works correctly, so render to <svg> instead.
var drawer = new SmilesDrawer.SvgDrawer({ width: 400, height: 300 });
drawer.draw(tree, svgId, isDark ? 'dark' : 'light');
btn.style.display = 'none';
} catch (e) {
btn.disabled = false;
btn.textContent = '⚠️ 렌더링 실패';
}
}, function() {
btn.disabled = false;
btn.textContent = '⚠️ SMILES 파싱 실패';
});
} catch (e) {
btn.disabled = false;
btn.textContent = '⚠️ 렌더링 실패';
}
var nodes = container.querySelectorAll('.smiles-inline:not([data-rendered])');
nodes.forEach(function(el) {
var smiles = el.getAttribute('data-smiles');
var svg = el.querySelector('svg');
if (!smiles || !svg) return;
el.setAttribute('data-rendered', '1');
try {
SmilesDrawer.parse(smiles, function(tree) {
try {
var smSize = smilesSizeFor(smiles);
var drawer = new SmilesDrawer.SvgDrawer({ width: smSize.width, height: smSize.height });
drawer.draw(tree, svg.id, isDark ? 'dark' : 'light');
} catch (e) {
el.innerHTML = '<span style="font-size:11px;color:var(--muted)" title="' + smiles + '">⚠️ 구조식 렌더링 실패</span>';
}
}, function() {
el.innerHTML = '<span style="font-size:11px;color:var(--muted)" title="' + smiles + '">⚠️ SMILES 파싱 실패</span>';
});
} catch (e) {}
});
}
function toggleMusicWidget(widgetId) {
+12 -2
View File
@@ -808,10 +808,9 @@
.yt-embed { width: 100%; aspect-ratio: 16/9; border: none; display: block; }
.windy-embed-wrap { margin: 10px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--line, #dde3f0); }
.windy-embed { width: 100%; height: 500px; border: none; display: block; }
.md-code-block { background: #1e1e2e; color: #cdd6f4; padding: 12px 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; margin: 8px 0; }
.md-code-block { background: var(--panel); color: var(--text); padding: 12px 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; margin: 8px 0; font-family: 'IBM Plex Mono', 'Fira Code', 'Courier New', monospace; white-space: pre; }
.md-inline-code { background: #e8edf6; color: #17243b; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
:root[data-theme="dark"] .md-inline-code { background: #2a2a3e; color: #cdd6f4; }
:root[data-theme="dark"] .md-code-block { background: #11111b; }
.download-btn { display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, #3b82f6, #2563eb); color: #fff !important; padding: 4px 10px; border-radius: 6px; text-decoration: none !important; font-weight: 600; font-size: 12px; margin: 4px 2px; transition: all 0.15s; box-shadow: 0 1px 3px rgba(37,99,235,0.25); cursor: pointer; }
.download-btn:hover { background: linear-gradient(135deg, #2563eb, #1d4ed8); transform: translateY(-1px); box-shadow: 0 2px 6px rgba(37,99,235,0.35); }
.download-icon { font-size: 12px; line-height: 1; }
@@ -2313,6 +2312,17 @@
display: block;
}
/* SMILES structure figure — textbook-style inline diagram, no header/button/border chrome */
.smiles-inline {
display: inline-block;
vertical-align: middle;
margin: 1px 2px;
}
.smiles-inline svg {
display: block;
max-width: 100%;
}
/* Music widget */
.music-widget {
border: 1px solid var(--line);