feat: Android App 原生 PDF 预览与前端 show_file 卡片
- 集成 AndroidPdfViewer (mhiew fork) 实现不依赖浏览器的 PDF 预览 - 新增 PdfPreviewActivity 与 JS Bridge (AndroidPdfBridge) - ShowFileCard 在 Android App 内对 PDF 显示「预览」按钮,不内嵌 iframe - 新增 /api/file/content 后端接口用于文件内容 inline 预览 - 前端支持 <show_file> 标签与 download:// 链接渲染 - 更新 Android 版本号至 1.0.35
This commit is contained in:
parent
53b3669f1d
commit
e4a663594d
@ -1,5 +1,8 @@
|
||||
# App 更新说明
|
||||
|
||||
# 1.0.35
|
||||
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器
|
||||
|
||||
# 1.0.32
|
||||
- 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化)
|
||||
- startRecordingInternal 增加详细日志,便于排查录音问题
|
||||
|
||||
@ -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/ 目录即可
|
||||
|
||||
@ -27,6 +27,12 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".PdfPreviewActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|uiMode" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -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?) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.github.barteksc.pdfviewer.PDFView
|
||||
android:id="@+id/pdfView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="invisible" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/loadingContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:attr/progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@ -82,6 +82,30 @@
|
||||
|
||||
**图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `<show_image>` 作为图片卡片展示
|
||||
|
||||
### 3.1.5 文件下载链接与文件预览卡片
|
||||
|
||||
当需要让用户下载或预览工作区内的文件时,有两种格式可用:
|
||||
|
||||
#### 1. 下载链接
|
||||
|
||||
[报告.pdf](download://output/report.pdf)
|
||||
|
||||
`href` 以 `download://` 开头,后跟工作区相对路径(如 `output/report.pdf`)。
|
||||
|
||||
#### 2. 文件预览卡片
|
||||
|
||||
<show_file path="output/report.pdf">
|
||||
</show_file>
|
||||
|
||||
只写 `path` 属性,路径为工作区相对路径。前端会自动按扩展名选择预览方式(文本/图片/PDF),并在卡片内直接展示内容。
|
||||
|
||||
#### 使用场景判断
|
||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
||||
|
||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||
|
||||
### 3.2 文件操作
|
||||
|
||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||
|
||||
@ -82,6 +82,30 @@
|
||||
|
||||
**图片检索流程**:Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → 用 `<show_image>` 作为图片卡片展示
|
||||
|
||||
### 3.1.5 文件下载链接与文件预览卡片
|
||||
|
||||
当需要让用户下载或预览工作区内的文件时,有两种格式可用:
|
||||
|
||||
#### 1. 下载链接
|
||||
|
||||
[报告.pdf](download://output/report.pdf)
|
||||
|
||||
`href` 以 `download://` 开头,后跟工作区相对路径(如 `output/report.pdf`)。
|
||||
|
||||
#### 2. 文件预览卡片
|
||||
|
||||
<show_file path="output/report.pdf">
|
||||
</show_file>
|
||||
|
||||
只写 `path` 属性,路径为工作区相对路径。前端会自动按扩展名选择预览方式(文本/图片/PDF),并在卡片内直接展示内容。
|
||||
|
||||
#### 使用场景判断
|
||||
- 用户说「给我这个文件」「下载 xxx」→ 用下载链接
|
||||
- 用户说「看一下这个文件」「展示 xxx 的内容」→ 用 `<show_file>` 卡片
|
||||
- 生成报告/数据文件后想展示成果 → 用 `<show_file>` 卡片
|
||||
|
||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||
|
||||
### 3.2 文件操作
|
||||
|
||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
1
static/icons/file-down.svg
Normal file
1
static/icons/file-down.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4m-8 10v-6m-3 3l3 3l3-3"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@ -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,
|
||||
|
||||
@ -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<Element, any>();
|
||||
|
||||
/**
|
||||
* 将 <show_file> 节点渲染为 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) 会被渲染为 <a class="md-download-link" href="download://..." data-path="/path">。
|
||||
*/
|
||||
function bindDownloadLinks(root: ParentNode) {
|
||||
// 优先用 data-download 属性选择(不被 sanitize 影响),
|
||||
// md-download-link class 作为兼容回退
|
||||
const links = root.querySelectorAll<HTMLAnchorElement>(
|
||||
'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;
|
||||
|
||||
|
||||
497
static/src/components/chat/ShowFileCard.vue
Normal file
497
static/src/components/chat/ShowFileCard.vue
Normal file
@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<div class="show-file-card">
|
||||
<div class="sfc-header">
|
||||
<span class="sfc-file-icon" aria-hidden="true"></span>
|
||||
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
||||
<div class="sfc-actions">
|
||||
<button
|
||||
v-if="canAndroidPdfPreview"
|
||||
type="button"
|
||||
class="sfc-btn sfc-btn-preview"
|
||||
@click="previewPdf"
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
v-if="canCopy"
|
||||
type="button"
|
||||
class="sfc-btn"
|
||||
@click="copyContent"
|
||||
>
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sfc-btn sfc-btn-download"
|
||||
@click="handleDownload"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sfc-preview" v-if="canPreview">
|
||||
<div class="sfc-loading" v-if="loading">
|
||||
<span class="sfc-spinner"></span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div class="sfc-error" v-else-if="error">{{ error }}</div>
|
||||
|
||||
<template v-else-if="fileType === 'text' || fileType === 'code' || fileType === 'json'">
|
||||
<pre class="sfc-code" v-html="highlightedCode"></pre>
|
||||
</template>
|
||||
|
||||
<template v-else-if="fileType === 'csv'">
|
||||
<div class="sfc-csv-scroll">
|
||||
<table class="sfc-csv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="(col, i) in csvHeader" :key="i">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in csvRows" :key="ri">
|
||||
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="sfc-csv-meta" v-if="csvTruncated">
|
||||
仅显示前 {{ csvRows.length }} 行,完整内容请下载查看
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="fileType === 'markdown'">
|
||||
<div class="sfc-md" v-html="renderedMarkdown"></div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="fileType === 'image'">
|
||||
<img
|
||||
class="sfc-image"
|
||||
:src="contentUrl"
|
||||
:alt="displayName"
|
||||
@click="openFullImage"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="fileType === 'pdf'">
|
||||
<iframe
|
||||
class="sfc-pdf"
|
||||
:src="contentUrl"
|
||||
:title="displayName"
|
||||
></iframe>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import Prism from 'prismjs';
|
||||
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
||||
|
||||
defineOptions({ name: 'ShowFileCard' });
|
||||
|
||||
const props = defineProps<{
|
||||
path: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}>();
|
||||
|
||||
// ---- 环境检测 ----
|
||||
const isAndroidApp = typeof window !== 'undefined' && Boolean((window as any).AndroidPdfBridge);
|
||||
|
||||
// ---- 状态 ----
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const rawContent = ref('');
|
||||
const copied = ref(false);
|
||||
let objectUrl: string | null = null;
|
||||
const contentUrl = ref('');
|
||||
|
||||
// ---- 计算属性 ----
|
||||
const fileType = computed(() => props.type || inferShowFileType(props.path));
|
||||
const displayName = computed(() => props.name || props.path.split('/').pop() || 'file');
|
||||
|
||||
const canPreview = computed(() => {
|
||||
if (props.preview === 'off') return false;
|
||||
// Android App 内 PDF 不走 iframe 内嵌预览,由原生 PdfPreviewActivity 处理
|
||||
if (isAndroidApp && fileType.value === 'pdf') return false;
|
||||
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
||||
});
|
||||
|
||||
const canAndroidPdfPreview = computed(() => {
|
||||
return isAndroidApp && fileType.value === 'pdf';
|
||||
});
|
||||
|
||||
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
|
||||
|
||||
const metaText = computed(() => {
|
||||
if (!rawContent.value) return '';
|
||||
const sizeKB = (new Blob([rawContent.value]).size / 1024).toFixed(1);
|
||||
return `${sizeKB} KB`;
|
||||
});
|
||||
|
||||
const highlightedCode = computed(() => {
|
||||
if (!rawContent.value) return '';
|
||||
let content = rawContent.value;
|
||||
if (fileType.value === 'json') {
|
||||
try { content = JSON.stringify(JSON.parse(content), null, 2); } catch {}
|
||||
}
|
||||
try {
|
||||
const grammar = Prism.languages[displayLang.value];
|
||||
if (grammar) return Prism.highlight(content, grammar, displayLang.value);
|
||||
} catch {}
|
||||
return content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
});
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
if (!rawContent.value) return '';
|
||||
return renderMarkdown(rawContent.value, false);
|
||||
});
|
||||
|
||||
const csvParsed = computed(() => {
|
||||
if (fileType.value !== 'csv' || !rawContent.value) return null;
|
||||
const lines = rawContent.value.split(/\r?\n/).filter(l => l.trim());
|
||||
const parseLine = (line: string): string[] => {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuote) {
|
||||
if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; }
|
||||
else if (ch === '"') inQuote = false;
|
||||
else current += ch;
|
||||
} else {
|
||||
if (ch === '"') inQuote = true;
|
||||
else if (ch === ',') { result.push(current); current = ''; }
|
||||
else current += ch;
|
||||
}
|
||||
}
|
||||
result.push(current);
|
||||
return result;
|
||||
};
|
||||
const maxRows = 100;
|
||||
const header = parseLine(lines[0] || '');
|
||||
const rows = lines.slice(1, maxRows + 1).map(parseLine);
|
||||
return { header, rows, truncated: lines.length > maxRows + 1 };
|
||||
});
|
||||
const csvHeader = computed(() => csvParsed.value?.header || []);
|
||||
const csvRows = computed(() => csvParsed.value?.rows || []);
|
||||
const csvTruncated = computed(() => csvParsed.value?.truncated || false);
|
||||
|
||||
// ---- 方法 ----
|
||||
function inferShowFileType(path: string): string {
|
||||
const extMap: Record<string, string> = {
|
||||
'.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'
|
||||
};
|
||||
const lowerPath = path.toLowerCase();
|
||||
const dotIdx = lowerPath.lastIndexOf('.');
|
||||
if (dotIdx >= 0) {
|
||||
const ext = lowerPath.slice(dotIdx);
|
||||
if (extMap[ext]) return extMap[ext];
|
||||
}
|
||||
return 'binary';
|
||||
}
|
||||
|
||||
function buildContentUrl() {
|
||||
return `/api/file/content?path=${encodeURIComponent(props.path)}`;
|
||||
}
|
||||
|
||||
async function loadContent() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
if (['image', 'pdf'].includes(fileType.value)) {
|
||||
contentUrl.value = buildContentUrl();
|
||||
} else {
|
||||
const resp = await fetch(buildContentUrl());
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
||||
error.value = msg || '加载失败';
|
||||
return;
|
||||
}
|
||||
rawContent.value = await resp.text();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || '网络错误';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
|
||||
const name = props.path.split('/').pop() || 'file';
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
||||
throw new Error(msg || '下载失败');
|
||||
}
|
||||
const blob = await resp.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 (e) {
|
||||
console.warn('[ShowFileCard] 下载失败:', e);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyContent() {
|
||||
if (!rawContent.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawContent.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => { copied.value = false; }, 2000);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openFullImage() {
|
||||
if (contentUrl.value) window.open(contentUrl.value, '_blank');
|
||||
}
|
||||
|
||||
function previewPdf() {
|
||||
const bridge = (window as any).AndroidPdfBridge;
|
||||
if (bridge && typeof bridge.previewPdf === 'function') {
|
||||
bridge.previewPdf(contentUrl.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onImageError() {
|
||||
error.value = '图片加载失败';
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (canPreview.value) {
|
||||
loadContent();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.show-file-card {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-base);
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sfc-header {
|
||||
min-height: 44px;
|
||||
background: var(--surface-raised);
|
||||
padding: 0 12px 0 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sfc-file-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--text-secondary);
|
||||
-webkit-mask: url('/static/icons/file-down.svg') no-repeat center / contain;
|
||||
mask: url('/static/icons/file-down.svg') no-repeat center / contain;
|
||||
}
|
||||
|
||||
.sfc-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sfc-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sfc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-base);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-muted);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.sfc-btn-download {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.sfc-btn-preview {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.sfc-preview {
|
||||
border-top: 1px solid var(--border-default);
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
background: var(--surface-base);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--text-muted) transparent;
|
||||
}
|
||||
|
||||
.sfc-preview::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sfc-preview::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sfc-preview::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sfc-loading,
|
||||
.sfc-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--text-tertiary);
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sfc-error {
|
||||
color: var(--state-error);
|
||||
}
|
||||
|
||||
.sfc-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: sfc-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sfc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.sfc-code {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.sfc-csv-scroll {
|
||||
overflow: auto;
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
.sfc-csv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sfc-csv-table th,
|
||||
.sfc-csv-table td {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sfc-csv-table th {
|
||||
background: var(--surface-soft);
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sfc-csv-meta {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.sfc-md {
|
||||
padding: 12px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sfc-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
margin: 0 auto;
|
||||
cursor: zoom-in;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sfc-pdf {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@ -283,6 +283,27 @@ function transformShowImageBlocks(raw: string) {
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理 <show_file> 标签(自闭合或开闭形式)。
|
||||
* 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. 自闭合:<show_file .../> 或 <show-file .../>
|
||||
let output = raw.replace(
|
||||
/<show[_-]file\b([^>]*?)\s*\/>/gi,
|
||||
(match, attrs: string) => `<show-file${attrs}></show-file>`
|
||||
);
|
||||
// 2. 开闭形式:<show_file ...> ... </show_file>,忽略内部内容,只保留属性
|
||||
output = output.replace(
|
||||
/<show[_-]file\b([^>]*)>([\s\S]*?)<\/show[_-]file>/gi,
|
||||
(match, attrs: string) => `<show-file${attrs}></show-file>`
|
||||
);
|
||||
return output;
|
||||
}
|
||||
|
||||
type RehypeProps = Record<string, unknown>;
|
||||
|
||||
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<string, string> = {
|
||||
'.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)
|
||||
* 输出:<a class="md-download-link" data-path="output/file.pdf" href="download://output/file.pdf">文件名.pdf</a>
|
||||
* 点击事件由 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<string, any> = {
|
||||
...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']
|
||||
},
|
||||
// 允许 <a href="download://..."> 链接
|
||||
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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user