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. **云端运行**:你在远程服务器上工作,在网页端和用户交互
|
1. **云端运行**:你在远程服务器上工作,在网页端和用户交互
|
||||||
2. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹
|
2. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹
|
||||||
|
|||||||
@ -65,6 +65,108 @@ import {
|
|||||||
stopResize as stopPanelResize
|
stopResize as stopPanelResize
|
||||||
} from './composables/usePanelResize';
|
} 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;
|
window.katex = katex;
|
||||||
|
|
||||||
@ -204,6 +306,7 @@ const appOptions = {
|
|||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.ensureScrollListener();
|
this.ensureScrollListener();
|
||||||
});
|
});
|
||||||
|
setupShowImageObserver();
|
||||||
|
|
||||||
// 延迟加载初始数据
|
// 延迟加载初始数据
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -351,6 +454,7 @@ const appOptions = {
|
|||||||
this.resourceStopContainerStatsPolling();
|
this.resourceStopContainerStatsPolling();
|
||||||
this.resourceStopProjectStoragePolling();
|
this.resourceStopProjectStoragePolling();
|
||||||
this.resourceStopUsageQuotaPolling();
|
this.resourceStopUsageQuotaPolling();
|
||||||
|
teardownShowImageObserver();
|
||||||
const cleanup = this.destroyEasterEggEffect(true);
|
const cleanup = this.destroyEasterEggEffect(true);
|
||||||
if (cleanup && typeof cleanup.catch === 'function') {
|
if (cleanup && typeof cleanup.catch === 'function') {
|
||||||
cleanup.catch(() => {});
|
cleanup.catch(() => {});
|
||||||
|
|||||||
@ -517,6 +517,48 @@
|
|||||||
line-height: 1.7;
|
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 {
|
.text-output .text-content {
|
||||||
padding: 0 20px 0 15px;
|
padding: 0 20px 0 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1242,6 +1242,34 @@ def admin_monitor_page():
|
|||||||
def admin_asset_file(filename: str):
|
def admin_asset_file(filename: str):
|
||||||
return send_from_directory(str(ADMIN_ASSET_DIR), filename)
|
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>')
|
@app.route('/static/<path:filename>')
|
||||||
def static_files(filename):
|
def static_files(filename):
|
||||||
"""提供静态文件"""
|
"""提供静态文件"""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user