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:
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 ``;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
// ` (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(/```([\w-]*)\n([\s\S]*?)```/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(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'");
|
||||
@@ -6362,23 +6374,20 @@ function renderMarkdown(text, opts) {
|
||||
// backslash (e.g. C/C=C/C), which would otherwise stay mangled as \.
|
||||
var smRaw = trimmed.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/gi,'"').replace(/'/g,"'").replace(/`/g,'`').replace(/$/g,'$').replace(/\/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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user