feat: support inline image rendering via show_image and user_upload route

This commit is contained in:
JOJO 2026-01-01 16:41:24 +08:00
parent 52f6135d37
commit f725212988
4 changed files with 183 additions and 0 deletions

View File

@ -9,6 +9,15 @@
- **文件管理**:创建、修改、重命名文件和文件夹
- **自动化任务**:批量处理文件、执行重复性工作
## 图片展示
- 如果需要直接在界面展示图片(本地或网络),请在回复里输出 `<show_image src="路径" alt="描述" />`,不用调用工具。
- `src` 支持以 `/` 开头的本地静态路径或 `http/https``alt` 可选,会显示在图片下方。
- 不要用 Markdown 图片语法或其它自定义标签。
- 示例:
- `<show_image src="/workspace/images/result.png" alt="最终渲染效果" />`
- `<show_image src="/workspace/cache/thumb.jpg" />`
- `<show_image src="https://example.com/demo.png" alt="官方示例截图" />`
## 重要提醒:你的工作环境
1. **云端运行**:你在远程服务器上工作,在网页端和用户交互
2. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹

View File

@ -65,6 +65,108 @@ import {
stopResize as stopPanelResize
} from './composables/usePanelResize';
function normalizeShowImageSrc(src: string) {
if (!src) return '';
const trimmed = src.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith('/user_upload/')) return trimmed;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
}
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
return trimmed;
}
return '';
}
function isSafeImageSrc(src: string) {
return !!normalizeShowImageSrc(src);
}
function escapeHtml(input: string) {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderShowImages(root: ParentNode | null = document) {
if (!root) return;
// 处理因自闭合解析导致的嵌套:把子 show_image 平铺到父后面
const nested = Array.from(root.querySelectorAll('show_image show_image')).reverse();
nested.forEach(child => {
const parent = child.parentElement;
if (parent && parent !== root) {
parent.after(child);
}
});
const nodes = Array.from(root.querySelectorAll('show_image:not([data-rendered])')).reverse();
nodes.forEach(node => {
// 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序
if (node.parentNode && node.firstChild) {
const parent = node.parentNode;
const ref = node.nextSibling; // 可能为 nullinsertBefore 会当 append
const children = Array.from(node.childNodes);
children.forEach(child => parent.insertBefore(child, ref));
}
const rawSrc = node.getAttribute('src') || '';
const mappedSrc = normalizeShowImageSrc(rawSrc);
if (!mappedSrc) {
node.setAttribute('data-rendered', '1');
node.setAttribute('data-rendered-error', 'invalid-src');
return;
}
const alt = node.getAttribute('alt') || '';
const safeAlt = escapeHtml(alt.trim());
const figure = document.createElement('figure');
figure.className = 'chat-inline-image';
const img = document.createElement('img');
img.loading = 'lazy';
img.src = mappedSrc;
img.alt = safeAlt;
img.onerror = () => {
figure.classList.add('chat-inline-image--error');
const tip = document.createElement('div');
tip.className = 'chat-inline-image__error';
tip.textContent = '图片加载失败';
figure.appendChild(tip);
};
figure.appendChild(img);
if (safeAlt) {
const caption = document.createElement('figcaption');
caption.innerHTML = safeAlt;
figure.appendChild(caption);
}
node.replaceChildren(figure);
node.setAttribute('data-rendered', '1');
});
}
let showImageObserver: MutationObserver | null = null;
function setupShowImageObserver() {
if (showImageObserver) return;
const container = document.querySelector('.messages-area') || document.body;
if (!container) return;
renderShowImages(container);
showImageObserver = new MutationObserver(() => renderShowImages(container));
showImageObserver.observe(container, { childList: true, subtree: true });
}
function teardownShowImageObserver() {
if (showImageObserver) {
showImageObserver.disconnect();
showImageObserver = null;
}
}
window.katex = katex;
@ -204,6 +306,7 @@ const appOptions = {
this.$nextTick(() => {
this.ensureScrollListener();
});
setupShowImageObserver();
// 延迟加载初始数据
setTimeout(() => {
@ -351,6 +454,7 @@ const appOptions = {
this.resourceStopContainerStatsPolling();
this.resourceStopProjectStoragePolling();
this.resourceStopUsageQuotaPolling();
teardownShowImageObserver();
const cleanup = this.destroyEasterEggEffect(true);
if (cleanup && typeof cleanup.catch === 'function') {
cleanup.catch(() => {});

View File

@ -517,6 +517,48 @@
line-height: 1.7;
}
.chat-inline-image {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin: 14px 0;
}
.chat-inline-image img {
display: block;
width: auto;
min-width: 160px;
max-width: 80%;
max-height: 720px;
object-fit: contain;
border-radius: 12px;
border: 1px solid var(--claude-border);
box-shadow: var(--claude-shadow);
background: var(--claude-card);
}
.chat-inline-image--error {
border: 1px dashed rgba(218, 119, 86, 0.6);
padding: 6px 10px;
border-radius: 12px;
background: rgba(218, 119, 86, 0.05);
}
.chat-inline-image__error {
font-size: 12px;
color: var(--claude-text-secondary);
text-align: center;
}
.chat-inline-image figcaption {
font-size: 12px;
color: var(--claude-text-secondary);
text-align: center;
line-height: 1.4;
padding: 0 8px;
}
.text-output .text-content {
padding: 0 20px 0 15px;
}

View File

@ -1242,6 +1242,34 @@ def admin_monitor_page():
def admin_asset_file(filename: str):
return send_from_directory(str(ADMIN_ASSET_DIR), filename)
@app.route('/user_upload/<path:filename>')
@login_required
def serve_user_upload(filename: str):
"""
直接向前端暴露当前登录用户的上传目录文件用于 <show_image src="/user_upload/..."> 等场景
- 仅登录用户可访问
- 路径穿越校验目标必须位于用户自己的 uploads_dir
"""
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = user_manager.ensure_user_workspace(user.username)
uploads_dir = workspace.uploads_dir.resolve()
target = (uploads_dir / filename).resolve()
try:
target.relative_to(uploads_dir)
except ValueError:
abort(403)
if not target.exists() or not target.is_file():
abort(404)
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
@app.route('/static/<path:filename>')
def static_files(filename):
"""提供静态文件"""