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):
"""提供静态文件"""