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:
JOJO 2026-06-24 00:06:10 +08:00
parent 53b3669f1d
commit e4a663594d
15 changed files with 1094 additions and 10 deletions

View File

@ -1,5 +1,8 @@
# App 更新说明
# 1.0.35
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer点击卡片「预览」按钮可在 App 内直接查看 PDF不再依赖浏览器
# 1.0.32
- 识别引擎改为预初始化模型下载完成后立即在后台加载用户点击时直接可用不再每次点击等3-5秒初始化
- startRecordingInternal 增加详细日志,便于排查录音问题

View File

@ -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/ 目录即可

View File

@ -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>

View File

@ -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?) {

View File

@ -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)
}
}
}

View File

@ -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>

View File

@ -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` 控制覆盖/追加)

View File

@ -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` 控制覆盖/追加)

View File

@ -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
)

View 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

View File

@ -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,

View File

@ -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;

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
});
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>

View File

@ -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);

View File

@ -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;
}
}