feat: support inline image rendering via show_image and user_upload route
This commit is contained in:
parent
52f6135d37
commit
f725212988
@ -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. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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; // 可能为 null,insertBefore 会当 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(() => {});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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):
|
||||
"""提供静态文件"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user