diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 7838af8..66d678b 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -9,6 +9,15 @@ - **文件管理**:创建、修改、重命名文件和文件夹 - **自动化任务**:批量处理文件、执行重复性工作 +## 图片展示 +- 如果需要直接在界面展示图片(本地或网络),请在回复里输出 ``,不用调用工具。 +- `src` 支持以 `/` 开头的本地静态路径或 `http/https`,`alt` 可选,会显示在图片下方。 +- 不要用 Markdown 图片语法或其它自定义标签。 +- 示例: + - `` + - `` + - `` + ## 重要提醒:你的工作环境 1. **云端运行**:你在远程服务器上工作,在网页端和用户交互 2. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹 diff --git a/static/src/app.ts b/static/src/app.ts index 597d1fd..6f662d7 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -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, '''); +} + +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(() => {}); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 453bfea..f58e565 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -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; } diff --git a/web_server.py b/web_server.py index e602da5..677528e 100644 --- a/web_server.py +++ b/web_server.py @@ -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/') +@login_required +def serve_user_upload(filename: str): + """ + 直接向前端暴露当前登录用户的上传目录文件,用于 等场景。 + - 仅登录用户可访问 + - 路径穿越校验:目标必须位于用户自己的 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/') def static_files(filename): """提供静态文件"""