diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md index 2dcc98b..a680ab2 100644 --- a/android-webview-app/APP_CHANGELOG.md +++ b/android-webview-app/APP_CHANGELOG.md @@ -1,5 +1,8 @@ # App 更新说明 +# 1.0.35 +- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器 + # 1.0.32 - 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化) - startRecordingInternal 增加详细日志,便于排查录音问题 diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts index d194ff6..7a8fb6f 100644 --- a/android-webview-app/app/build.gradle.kts +++ b/android-webview-app/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.cyjai.agent" minSdk = 24 targetSdk = 35 - versionCode = 36 - versionName = "1.0.34" + versionCode = 37 + versionName = "1.0.35" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -66,6 +66,9 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + // PDF 预览(不依赖浏览器) + implementation("com.github.mhiew:android-pdf-viewer:3.2.0-beta.3") + // sherpa-onnx 语音识别库(需手动下载 AAR 放到 app/libs/) // 下载地址:https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar // 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可 diff --git a/android-webview-app/app/src/main/AndroidManifest.xml b/android-webview-app/app/src/main/AndroidManifest.xml index 0e9c242..0ad7cdd 100644 --- a/android-webview-app/app/src/main/AndroidManifest.xml +++ b/android-webview-app/app/src/main/AndroidManifest.xml @@ -27,6 +27,12 @@ + + diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt index d5bda44..29076d4 100644 --- a/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.util.Log import android.view.ViewGroup import android.view.View +import android.content.Context import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.PermissionRequest @@ -100,6 +101,7 @@ class MainActivity : AppCompatActivity() { webView.clearCache(true) webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge") + webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge") // 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化) voiceBridge = VoiceBridge(this@MainActivity, webView) @@ -367,6 +369,21 @@ class MainActivity : AppCompatActivity() { webView.evaluateJavascript(script, null) } + inner class PdfPreviewBridge { + @JavascriptInterface + fun previewPdf(pdfUrl: String?) { + val url = pdfUrl ?: return + runOnUiThread { + val intent = Intent(this@MainActivity, PdfPreviewActivity::class.java) + intent.putExtra(PdfPreviewActivity.EXTRA_PDF_URL, url) + startActivity(intent) + } + } + + @JavascriptInterface + fun isPdfPreviewSupported(): Boolean = true + } + inner class ThemeBridge { @JavascriptInterface fun onThemeChanged(theme: String?) { diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/PdfPreviewActivity.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/PdfPreviewActivity.kt new file mode 100644 index 0000000..026d500 --- /dev/null +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/PdfPreviewActivity.kt @@ -0,0 +1,150 @@ +package com.cyjai.agent + +import android.net.Uri +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.github.barteksc.pdfviewer.PDFView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +class PdfPreviewActivity : AppCompatActivity() { + + companion object { + const val EXTRA_PDF_URL = "pdf_url" + private const val HOME_URL = "https://agent.cyjai.com" + } + + private lateinit var pdfView: PDFView + private lateinit var loadingContainer: LinearLayout + private lateinit var progressBar: ProgressBar + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_pdf_preview) + + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + title = "PDF 预览" + } + + pdfView = findViewById(R.id.pdfView) + loadingContainer = findViewById(R.id.loadingContainer) + progressBar = findViewById(R.id.progressBar) + statusText = findViewById(R.id.statusText) + + val rawUrl = intent.getStringExtra(EXTRA_PDF_URL) ?: "" + if (rawUrl.isBlank()) { + showError("PDF 地址为空") + return + } + + val url = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { + rawUrl + } else { + HOME_URL.trimEnd('/') + (if (rawUrl.startsWith("/")) rawUrl else "/$rawUrl") + } + + loadPdf(url) + } + + private fun showLoading(msg: String) { + loadingContainer.visibility = View.VISIBLE + progressBar.visibility = View.VISIBLE + statusText.visibility = View.VISIBLE + statusText.text = msg + pdfView.visibility = View.INVISIBLE + } + + private fun hideLoading() { + loadingContainer.visibility = View.GONE + progressBar.visibility = View.GONE + statusText.visibility = View.GONE + pdfView.visibility = View.VISIBLE + } + + private fun showError(msg: String) { + loadingContainer.visibility = View.VISIBLE + progressBar.visibility = View.GONE + statusText.visibility = View.VISIBLE + statusText.text = msg + pdfView.visibility = View.INVISIBLE + Toast.makeText(this, msg, Toast.LENGTH_LONG).show() + } + + private fun loadPdf(url: String) { + showLoading("加载中...") + lifecycleScope.launch(Dispatchers.IO) { + try { + val file = downloadToCache(url) + withContext(Dispatchers.Main) { + showPdf(file) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + showError("PDF 加载失败: ${e.message}") + } + } + } + } + + private fun downloadToCache(url: String): File { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 60_000 + conn.setRequestProperty("Accept", "application/pdf,*/*") + conn.connect() + + if (conn.responseCode !in 200..299) { + throw RuntimeException("HTTP ${conn.responseCode}") + } + + val pathParam = Uri.parse(url).getQueryParameter("path") + val fileName = if (!pathParam.isNullOrBlank()) { + "preview_" + pathParam.replace("/", "_") + } else { + "preview_${System.currentTimeMillis()}.pdf" + } + val file = File(cacheDir, fileName) + + FileOutputStream(file).use { out -> + conn.inputStream.use { input -> + input.copyTo(out) + } + } + conn.disconnect() + return file + } + + private fun showPdf(file: File) { + pdfView.fromFile(file) + .enableSwipe(true) + .swipeHorizontal(false) + .enableDoubletap(true) + .defaultPage(0) + .onError { showError("PDF 渲染失败") } + .onLoad { hideLoading() } + .load() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == android.R.id.home) { + finish() + true + } else { + super.onOptionsItemSelected(item) + } + } +} diff --git a/android-webview-app/app/src/main/res/layout/activity_pdf_preview.xml b/android-webview-app/app/src/main/res/layout/activity_pdf_preview.xml new file mode 100644 index 0000000..b403f5e --- /dev/null +++ b/android-webview-app/app/src/main/res/layout/activity_pdf_preview.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/prompts/main_system.txt b/prompts/main_system.txt index b1e7530..a0f1b12 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -82,6 +82,30 @@ **图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `` 作为图片卡片展示 +### 3.1.5 文件下载链接与文件预览卡片 + +当需要让用户下载或预览工作区内的文件时,有两种格式可用: + +#### 1. 下载链接 + +[报告.pdf](download://output/report.pdf) + +`href` 以 `download://` 开头,后跟工作区相对路径(如 `output/report.pdf`)。 + +#### 2. 文件预览卡片 + + + + +只写 `path` 属性,路径为工作区相对路径。前端会自动按扩展名选择预览方式(文本/图片/PDF),并在卡片内直接展示内容。 + +#### 使用场景判断 +- 用户说「给我这个文件」「下载 xxx」→ 用下载链接 +- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `` 卡片 +- 生成报告/数据文件后想展示成果 → 用 `` 卡片 + +注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 + ### 3.2 文件操作 - `write_file`:写入文件(`append` 控制覆盖/追加) diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index 5ded59a..8b65c2e 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -82,6 +82,30 @@ **图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `` 作为图片卡片展示 +### 3.1.5 文件下载链接与文件预览卡片 + +当需要让用户下载或预览工作区内的文件时,有两种格式可用: + +#### 1. 下载链接 + +[报告.pdf](download://output/report.pdf) + +`href` 以 `download://` 开头,后跟工作区相对路径(如 `output/report.pdf`)。 + +#### 2. 文件预览卡片 + + + + +只写 `path` 属性,路径为工作区相对路径。前端会自动按扩展名选择预览方式(文本/图片/PDF),并在卡片内直接展示内容。 + +#### 使用场景判断 +- 用户说「给我这个文件」「下载 xxx」→ 用下载链接 +- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `` 卡片 +- 生成报告/数据文件后想展示成果 → 用 `` 卡片 + +注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 + ### 3.2 文件操作 - `write_file`:写入文件(`append` 控制覆盖/追加) diff --git a/server/chat/files.py b/server/chat/files.py index a1da414..b85d7f9 100644 --- a/server/chat/files.py +++ b/server/chat/files.py @@ -12,6 +12,7 @@ from flask import Blueprint, jsonify, request, session, send_file from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge import secrets +import mimetypes from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS from modules.personalization_manager import ( @@ -242,3 +243,73 @@ def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, usernam as_attachment=True, download_name=f"{folder_name}.zip" ) + + +# 文件预览允许 inline 展示的 MIME 前缀白名单 +# 任何不在此白名单内的类型一律退化为 application/octet-stream(浏览器会触发下载) +_FILE_CONTENT_INLINE_MIME_PREFIXES = ( + 'text/', + 'image/', + 'application/pdf', + 'application/json', + 'application/javascript', + 'application/xml', +) + +# 允许 inline 预览的文本类扩展名(mimetypes 未识别时兑底) +_FILE_CONTENT_INLINE_TEXT_EXTS = { + '.txt', '.md', '.markdown', '.csv', '.json', '.json5', '.yaml', '.yml', + '.js', '.ts', '.jsx', '.tsx', '.vue', '.py', '.rb', '.go', '.rs', + '.java', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.swift', '.kt', + '.sh', '.bash', '.zsh', '.ps1', '.bat', '.cmd', + '.html', '.htm', '.css', '.scss', '.sass', '.less', + '.xml', '.svg', '.toml', '.ini', '.cfg', '.conf', '.log', '.sql', +} + + +def _resolve_inline_mime(full_path: Path) -> str: + """猜测 inline 预览用的 MIME 类型,不在白名单内的退化为 octet-stream。""" + guessed, _ = mimetypes.guess_type(str(full_path)) + if guessed: + if guessed.startswith(_FILE_CONTENT_INLINE_MIME_PREFIXES): + return guessed + return 'application/octet-stream' + # mimetypes 未识别,按扩展名兑底 + suffix = full_path.suffix.lower() + if suffix in _FILE_CONTENT_INLINE_TEXT_EXTS: + return 'text/plain; charset=utf-8' + if suffix == '.pdf': + return 'application/pdf' + return 'application/octet-stream' + + +@chat_bp.route('/api/file/content') +@api_login_required +@with_terminal +def file_content_api(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """以 inline 方式返回文件内容,用于前端预览(区别于 /api/download/file 的 attachment)。 + + 路径校验复用 _validate_path,与下载接口同等安全级别。 + 非白名单 MIME 退化为 octet-stream,避免浏览器误执行 HTML/JS。""" + path = (request.args.get('path') or '').strip() + if not path: + return jsonify({"success": False, "error": "缺少路径参数"}), 400 + + valid, error, full_path = terminal.file_manager._validate_path(path) + if not valid or full_path is None: + return jsonify({"success": False, "error": error or "路径校验失败"}), 400 + if not full_path.exists() or not full_path.is_file(): + return jsonify({"success": False, "error": "文件不存在"}), 404 + + mime_type = _resolve_inline_mime(full_path) + + # 永远不要让浏览器 inline 执行 HTML/JS(防 XSS); + # HTML 文件强制走下载,不 inline 预览 + if mime_type.startswith('text/html'): + mime_type = 'application/octet-stream' + + return send_file( + full_path, + mimetype=mime_type, + as_attachment=False + ) diff --git a/static/icons/file-down.svg b/static/icons/file-down.svg new file mode 100644 index 0000000..3a65851 --- /dev/null +++ b/static/icons/file-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/src/App.vue b/static/src/App.vue index 2758711..7d3cfcc 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -851,8 +851,11 @@ const VirtualMonitorSurface = defineAsyncComponent( const PathAuthorizationDialog = defineAsyncComponent( () => import('./components/overlay/PathAuthorizationDialog.vue') ); + const tutorialStore = useTutorialStore(); + + const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href; const detectAppShell = () => { if (typeof window === 'undefined') return false; @@ -870,6 +873,7 @@ onMounted(() => { window.setTimeout(() => { isAppShell.value = detectAppShell(); }, 300); + }); const mobileMenuIcons = { workspace: new URL('../icons/folder.svg', import.meta.url).href, diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index fd02fca..1618798 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -1,6 +1,8 @@ // @ts-nocheck import katex from 'katex'; import DOMPurify from 'dompurify'; +import { createApp } from 'vue'; +import ShowFileCard from '../components/chat/ShowFileCard.vue'; let showImageRenderSeq = 0; let showImageDebugLogCount = 0; @@ -430,10 +432,17 @@ function renderShowImages(root: ParentNode | null = document) { const htmlNodes = Array.from( root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])') ); - if (htmlNodes.length > 0) { + const fileNodes = Array.from( + root.querySelectorAll('show_file:not([data-rendered]), show-file:not([data-rendered])') + ); + // download:// 链接也需要绑定,即使没有 show 标签 + const hasDownloadLinks = !!root.querySelector( + 'a.md-download-link:not([data-download-bound]), a[data-download="1"]:not([data-download-bound])' + ); + if (htmlNodes.length > 0 || fileNodes.length > 0) { markShowTagDrawingActive(); } - if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0) return; + if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0 && fileNodes.length === 0 && !hasDownloadLinks) return; const rootNode = root as Node; debugShowImageLog('render:start', { @@ -444,6 +453,7 @@ function renderShowImages(root: ParentNode | null = document) { : `nodeType:${rootNode?.nodeType}`, pendingImageCount: imageNodes.length, pendingHtmlCount: htmlNodes.length, + pendingFileCount: fileNodes.length, orderBefore: collectShowImageOrder(root) }); @@ -931,6 +941,16 @@ function renderShowImages(root: ParentNode | null = document) { } }); + // ===== show_file 预览卡片 ===== + fileNodes.forEach((node) => { + if (!node.isConnected || node.hasAttribute('data-rendered')) return; + renderShowFileCard(node); + node.setAttribute('data-rendered', '1'); + }); + + // ===== download:// 链接事件绑定 ===== + bindDownloadLinks(root); + debugShowImageLog('render:end', { renderId, orderAfter: collectShowImageOrder(root) @@ -1222,15 +1242,15 @@ export function setupShowImageObserver() { const hasShowImageRelatedMutation = mutations.some((mutation) => { const targetIsShowImage = mutation.target instanceof Element && - ['show_image', 'show_html', 'show-image', 'show-html'].includes( + ['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes( mutation.target.tagName.toLowerCase() ); const addedHasShowTag = Array.from(mutation.addedNodes).some( (n) => n instanceof Element && - (['show_image', 'show_html', 'show-image', 'show-html'].includes( + (['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes( n.tagName.toLowerCase() - ) || !!n.querySelector?.('show_image,show_html,show-image,show-html')) + ) || !!n.querySelector?.('show_image,show_html,show_file,show-image,show-html,show-file,a.md-download-link,a[data-download="1"]')) ); return targetIsShowImage || addedHasShowTag; }); @@ -1325,6 +1345,94 @@ function updateViewportHeightVar() { } } +// ===== show_file 预览卡片渲染 ===== + +function dispatchShowFileDownload(path: string) { + // 直接 fetch 下载接口,不走 Vue 组件链 + const url = `/api/download/file?path=${encodeURIComponent(path)}`; + const name = path.split('/').pop() || 'file'; + fetch(url) + .then((resp) => { + if (!resp.ok) { + return resp.json().then((j) => { + throw new Error(j.error || j.message || resp.statusText); + }).catch(() => { + throw new Error(resp.statusText || '下载失败'); + }); + } + return resp.blob(); + }) + .then((blob) => { + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + }) + .catch((err) => { + console.warn('[show_file] 下载失败:', err); + // 兜底:直接跳转 + window.open(url, '_blank'); + }); +} + +// 跟踪已挂载的 show_file Vue 应用,便于卸载 +const showFileAppMap = new WeakMap(); + +/** + * 将 节点渲染为 Vue 预览卡片组件。 + * 只读取 path 属性,预览内容直接在卡片内部展开。 + */ +function renderShowFileCard(node: Element) { + const path = (node.getAttribute('path') || '').trim().replace(/^\/+/, ''); + + if (!path) { + node.replaceChildren(document.createTextNode('[show_file: 缺少 path 属性]')); + return; + } + + // 清理旧实例 + const oldApp = showFileAppMap.get(node); + if (oldApp) { + try { oldApp.unmount(); } catch {} + showFileAppMap.delete(node); + } + + const mountEl = document.createElement('div'); + mountEl.className = 'show-file-card-root'; + node.replaceChildren(mountEl); + + const app = createApp(ShowFileCard, { path }); + app.mount(mountEl); + showFileAppMap.set(node, app); +} + +/** + * 绑定 download:// 链接的点击事件。 + * markdown 里的 [文件](download:///path) 会被渲染为 。 + */ +function bindDownloadLinks(root: ParentNode) { + // 优先用 data-download 属性选择(不被 sanitize 影响), + // md-download-link class 作为兼容回退 + const links = root.querySelectorAll( + 'a[data-download="1"]:not([data-download-bound]), a.md-download-link:not([data-download-bound])' + ); + links.forEach((link) => { + link.setAttribute('data-download-bound', '1'); + link.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const path = link.getAttribute('data-path') || ''; + if (path) { + dispatchShowFileDownload(path); + } + }); + }); +} + if (typeof window !== 'undefined') { window.katex = katex; diff --git a/static/src/components/chat/ShowFileCard.vue b/static/src/components/chat/ShowFileCard.vue new file mode 100644 index 0000000..de689da --- /dev/null +++ b/static/src/components/chat/ShowFileCard.vue @@ -0,0 +1,497 @@ + + + + + {{ displayName }} + + + 预览 + + + {{ copied ? '已复制' : '复制' }} + + + 下载 + + + + + + + + 加载中... + + + {{ error }} + + + + + + + + + + + {{ col }} + + + + + {{ cell }} + + + + + + 仅显示前 {{ csvRows.length }} 行,完整内容请下载查看 + + + + + + + + + + + + + + + + + + + + + diff --git a/static/src/composables/useMarkdownRenderer.ts b/static/src/composables/useMarkdownRenderer.ts index a86522a..3ec1df1 100644 --- a/static/src/composables/useMarkdownRenderer.ts +++ b/static/src/composables/useMarkdownRenderer.ts @@ -283,6 +283,27 @@ function transformShowImageBlocks(raw: string) { return output; } +/** + * 预处理 标签(自闭合或开闭形式)。 + * markdown parser 在 inline 位置会把自定义标签当文本转义, + * 转成显式闭合形式后可以被 rehypeRaw 识别为 raw HTML 元素。 + * show_file 不含富文本内容,不需要 base64 编码。 + */ +function transformShowFileBlocks(raw: string) { + if (!raw || (raw.indexOf('show_file') === -1 && raw.indexOf('show-file') === -1)) return raw; + // 1. 自闭合: 或 + let output = raw.replace( + /]*?)\s*\/>/gi, + (match, attrs: string) => `` + ); + // 2. 开闭形式: ... ,忽略内部内容,只保留属性 + output = output.replace( + /]*)>([\s\S]*?)<\/show[_-]file>/gi, + (match, attrs: string) => `` + ); + return output; +} + type RehypeProps = Record; function readAttr(props: RehypeProps | undefined, name: string): string { @@ -305,6 +326,42 @@ function writeAttr(props: RehypeProps, name: string, value: string | null) { props[name] = value; } +// show_file 预览类型:text/code/json/csv/markdown/image/pdf/binary +const SHOW_FILE_PREVIEW_TYPES = new Set([ + 'text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf', 'binary' +]); + +const SHOW_FILE_EXT_TO_TYPE: Record = { + '.txt': 'text', + '.md': 'markdown', '.markdown': 'markdown', + '.json': 'json', '.json5': 'json', + '.csv': 'csv', '.tsv': 'csv', + '.yaml': 'text', '.yml': 'text', '.toml': 'text', + '.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text', + '.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code', + '.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code', + '.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code', + '.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code', + '.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code', + '.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code', + '.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code', + '.png': 'image', '.jpg': 'image', '.jpeg': 'image', + '.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image', + '.avif': 'image', + '.pdf': 'pdf', +}; + +function inferShowFileType(path: string, explicit?: string): string { + if (explicit && SHOW_FILE_PREVIEW_TYPES.has(explicit)) return explicit; + const lowerPath = path.toLowerCase(); + const dotIdx = lowerPath.lastIndexOf('.'); + if (dotIdx >= 0) { + const ext = lowerPath.slice(dotIdx); + if (SHOW_FILE_EXT_TO_TYPE[ext]) return SHOW_FILE_EXT_TO_TYPE[ext]; + } + return 'binary'; +} + function normalizeShowTagsPlugin() { return (tree: any) => { visit(tree, 'element', (node: any) => { @@ -335,6 +392,47 @@ function normalizeShowTagsPlugin() { } node.properties = props; } + if (node.tagName === 'show_file' || node.tagName === 'show-file') { + node.tagName = 'show_file'; + const props = (node.properties || {}) as RehypeProps; + const rawPath = readAttr(props, 'path').trim(); + const path = rawPath.replace(/^\/+/, ''); + writeAttr(props, 'path', path); + node.properties = props; + } + }); + }; +} + +/** + * 拦截 markdown 链接里的 download:// 协议,标记为下载链接。 + * 输入:[文件名.pdf](download:///output/file.pdf) 或 (download://output/file.pdf) + * 输出:文件名.pdf + * 点击事件由 bootstrap.ts 统一拦截。 + */ +function transformDownloadLinksPlugin() { + return (tree: any) => { + visit(tree, 'element', (node: any) => { + if (!node || node.tagName !== 'a' || !node.properties) return; + const href = node.properties.href; + if (typeof href !== 'string') return; + // 兼容 download:///path(三个斜杠,旧写法)和 download://path(两个斜杠,推荐写法) + const match = href.match(/^download:\/\/\/?(.*)$/i); + if (!match) return; + const rawPath = match[1]; + const path = rawPath.replace(/^\/+/, ''); + if (!path) return; + node.properties.href = `download://${path}`; + node.properties['data-path'] = path; + node.properties['data-download'] = '1'; + const existingClass = node.properties.className; + if (Array.isArray(existingClass)) { + if (!existingClass.includes('md-download-link')) { + existingClass.push('md-download-link'); + } + } else { + node.properties.className = ['md-download-link']; + } }); }; } @@ -361,14 +459,36 @@ function wrapMarkdownTablesPlugin() { const sanitizedSchema: Record = { ...defaultSchema, - tagNames: [...(defaultSchema.tagNames || []), 'show_html', 'show_image', 'show-html', 'show-image'], + tagNames: [ + ...(defaultSchema.tagNames || []), + 'show_html', 'show_image', 'show_file', + 'show-html', 'show-image', 'show-file' + ], attributes: { ...(defaultSchema.attributes || {}), div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'], + a: [ + 'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy', + 'dataFootnoteBackref', 'dataFootnoteRef', + 'data-path', 'data-download', + 'href', + // className: 合并默认的 data-footnote-backref 和我们的 md-download-link + ['className', 'data-footnote-backref', 'md-download-link'] + ], show_html: ['ratio', 'js', 'data-encoded', 'data-partial'], show_image: ['src', 'alt', 'title', 'width', 'height'], + show_file: ['path'], 'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'], - 'show-image': ['src', 'alt', 'title', 'width', 'height'] + 'show-image': ['src', 'alt', 'title', 'width', 'height'], + 'show-file': ['path'] + }, + // 允许 链接 + protocols: { + ...(defaultSchema.protocols || {}), + href: [ + ...((defaultSchema.protocols || {}).href || []), + 'download' + ] } }; @@ -379,6 +499,7 @@ const markdownProcessor = unified() .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) .use(normalizeShowTagsPlugin) + .use(transformDownloadLinksPlugin) .use(wrapMarkdownTablesPlugin) .use(rehypeSanitize, sanitizedSchema) .use(rehypeStringify, { allowDangerousHtml: false }); @@ -468,7 +589,9 @@ export function renderMarkdown(text: string, isStreaming = false) { } } - const safeText = transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming)); + const safeText = transformShowFileBlocks( + transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming)) + ); let html = ''; try { html = renderMarkdownToHtml(safeText); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 1b128ed..be71f12 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -1530,3 +1530,22 @@ show-html:not([data-rendered='1'])[ratio='3:4'] { opacity: 0; } } + +// ===== show_file 预览卡片外层容器 ===== +// 实际样式由 ShowFileCard.vue 的 scoped style 控制, +// 这里只保留对 .show-file-card-root 的最小兜底 +.show-file-card-root { + max-width: 100%; +} + +// ===== download:// 链接样式 ===== +a.md-download-link { + color: var(--accent-primary); + text-decoration: underline; + cursor: pointer; + transition: opacity 0.15s; + + &:hover { + opacity: 0.8; + } +}