mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
fix(ui): raw SVG markup displayed instead of search icon for web_search tool label (#3601)
* fix(ui): escaped SVG renders as raw markup during web_search tool label The _toolLabels['web_search'] entry embedded an SVG HTML string concatenated with label text. At render time the entire value was passed through esc(), HTML-escaping <svg> tags so the icon displayed as raw text instead of rendering visually. Fix: separate icon from label text via a _toolIcons map. The SVG is injected as raw innerHTML (unescaped) in .agent-thread-icon, while the label text remains safely escaped. * test: add behavioral test for web_search tool icon rendering Co-authored-by: TheDragonTail <jakeoldfield2@gmail.com> --------- Co-authored-by: TheDragonTail <jakeoldfield2@gmail.com>
This commit is contained in:
+6
-2
@@ -1082,7 +1082,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let _lastToolName = '';
|
||||
const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
const _toolLabels = {
|
||||
'web_search': _searchIcon + 'Searching',
|
||||
'web_search': 'Searching',
|
||||
'bash': 'Running',
|
||||
'python': 'Running',
|
||||
'create_document': 'Writing',
|
||||
@@ -1102,6 +1102,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
'list_models': 'Browsing',
|
||||
'ui_control': 'Adjusting',
|
||||
};
|
||||
const _toolIcons = {
|
||||
'web_search': _searchIcon,
|
||||
};
|
||||
function _thinkingLabel() {
|
||||
if (!_lastToolName) {
|
||||
return 'Thinking';
|
||||
@@ -2049,10 +2052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
threadWrap.classList.add('streaming');
|
||||
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
|
||||
const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6';
|
||||
const node = document.createElement('div')
|
||||
node.className = 'agent-thread-node running';
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${toolIcon}</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
// Expand/collapse via delegated click handler (init at module bottom).
|
||||
threadWrap.appendChild(node);
|
||||
currentToolBubble = node;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Pin the web_search tool-icon rendering in the agent thread (PR #??).
|
||||
|
||||
Verifies:
|
||||
- web_search renders an <svg> icon instead of raw markup
|
||||
- Other tools get the default ▶ icon
|
||||
- Hostile tool names are HTML-escaped in the label
|
||||
|
||||
Pure JS via node --input-type=module (same approach as
|
||||
test_composer_arrow_up_recall_js.py). Skips when node is not installed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
_CHECK_JS = r"""
|
||||
function esc(s) {
|
||||
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return (s || '').replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
|
||||
const _toolLabels = {
|
||||
web_search: 'Searching',
|
||||
bash: 'Running',
|
||||
};
|
||||
|
||||
const _toolIcons = {
|
||||
web_search: _searchIcon,
|
||||
};
|
||||
|
||||
function renderIcon(toolName) {
|
||||
return _toolIcons[toolName.toLowerCase()] || '\u25B6';
|
||||
}
|
||||
|
||||
function renderLabel(toolName) {
|
||||
return _toolLabels[toolName.toLowerCase()] || toolName;
|
||||
}
|
||||
|
||||
function renderThreadHTML(toolName, cmd) {
|
||||
const label = renderLabel(toolName);
|
||||
const icon = renderIcon(toolName);
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
return `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${icon}</span><span class="agent-thread-tool">${esc(label)}</span><span class="agent-thread-wave">\u2581\u2582\u2583</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
}
|
||||
|
||||
const cases = CASES_JSON;
|
||||
const results = cases.map(c => {
|
||||
const html = renderThreadHTML(c.tool, c.cmd || '');
|
||||
return { tool: c.tool, html };
|
||||
});
|
||||
console.log(JSON.stringify(results));
|
||||
"""
|
||||
|
||||
|
||||
def _run(cases: list) -> list:
|
||||
js = _CHECK_JS.replace("CASES_JSON", json.dumps(cases))
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(_REPO),
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_web_search_icon_contains_svg():
|
||||
out = _run([{"tool": "web_search"}])[0]
|
||||
assert "<svg" in out["html"], "Expected <svg> in agent-thread-icon for web_search"
|
||||
assert "Searching" in out["html"], "Expected 'Searching' label for web_search"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_default_tool_icon_is_triangle():
|
||||
out = _run([{"tool": "bash"}])[0]
|
||||
assert "▶" in out["html"], "Expected ▶ icon for tools without custom icon"
|
||||
assert "<svg" not in out["html"], "Expected no <svg> for bash"
|
||||
assert "Running" in out["html"], "Expected 'Running' label for bash"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_unknown_tool_falls_back_to_name():
|
||||
out = _run([{"tool": "my_custom_tool"}])[0]
|
||||
assert "▶" in out["html"], "Expected ▶ for unknown tool"
|
||||
assert "my_custom_tool" in out["html"], "Expected tool name as label"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_hostile_tool_name_is_escaped():
|
||||
out = _run([{"tool": '<img src=x onerror="alert(1)">'}])[0]
|
||||
assert "<img" in out["html"], "Expected < to be HTML-escaped"
|
||||
assert ">" in out["html"], "Expected > to be HTML-escaped"
|
||||
assert "<img" not in out["html"], "Raw <img> must not appear"
|
||||
assert "onerror" not in out["html"] or """ in out["html"], "onerror must not be executable"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_unknown_tool_case_insensitive_matches_icons():
|
||||
out = _run([{"tool": "WEB_SEARCH"}, {"tool": "Web_Search"}])
|
||||
for r in out:
|
||||
assert "<svg" in r["html"], f"Expected SVG for case-variant '{r['tool']}'"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_command_is_escaped():
|
||||
out = _run([{"tool": "bash", "cmd": "echo $HOME && ls"}])[0]
|
||||
assert "echo $HOME" in out["html"], "Expected command text in output"
|
||||
Reference in New Issue
Block a user