feat(plugin): add document export, doc‑block, and TTS/ASR support

Adds a DocBlock component that renders embedded documents, new export buttons for DOCX
and PDF, and updates the file‑upload picker to accept *.txt, *.docx, *.pptx, and *.pdf.
Introduces a DOCX→PDF conversion bridge in the backend and new /tts and /asr
endpoints that expose TTS and speech‑recognition functionality.  The README is
rewritten to describe the new features and clean up legacy documentation.  All
changes are backward‑compatible and do not introduce breaking API changes.
This commit is contained in:
2026-04-04 23:56:18 +08:00
parent be4000b774
commit 9ff51ac2f3
25 changed files with 2995 additions and 1124 deletions
+197 -118
View File
@@ -1,250 +1,329 @@
<template>
<div class="doc-block-crepe" :class="{ collapsed: isCollapsed }">
<div class="doc-block-crepe" :class="{ collapsed: collapsedState }">
<div class="doc-header">
<div class="doc-accent"></div>
<div class="doc-icon">
<svg v-if="docType === 'pdf'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M9 15v-2h6v2"/>
<path d="M12 13v4"/>
<path d="M8 13h5"/>
<path d="M8 17h8"/>
</svg>
<svg v-else-if="docType === 'doc'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg v-else-if="docType === 'docx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M10 9H8"/>
<path d="m8 13 2 4 2-4 2 4 2-4"/>
</svg>
<svg v-else-if="docType === 'ppt'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8"/>
<path d="M12 17v4"/>
<svg v-else-if="docType === 'pptx'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2"/>
<path d="M8 20h8"/>
<path d="M12 16v4"/>
<path d="M9 8h3a2 2 0 0 1 0 4H9z"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M16 13H8"/>
<path d="M16 17H8"/>
<path d="M8 13h8"/>
<path d="M8 17h5"/>
</svg>
</div>
<div class="doc-name">{{ docName }}</div>
<div class="doc-meta">
<div class="doc-name">{{ docName }}</div>
<div class="doc-subline">{{ typeLabel }} · {{ displayTime }}</div>
</div>
<div class="doc-actions">
<button @click="downloadDoc" class="action-btn" title="下载文档">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="toggleCollapse" class="action-btn collapse-btn" :title="isCollapsed ? '展开' : '折叠'">
<svg v-if="isCollapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button type="button" class="action-btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<button type="button" class="action-btn action-btn-danger" title="删除文件" @click="props.onDelete?.()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M8 6V4h8v2"/>
<path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
</svg>
</button>
</div>
</div>
<div class="doc-editor" v-show="!isCollapsed">
<div v-show="!collapsedState" class="doc-editor">
<div ref="editorRoot" class="inner-crepe"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { replaceAll } from '@milkdown/kit/utils'
import { Crepe } from '@milkdown/crepe'
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, setCopilotEnabled } from '../plugins/copilotPlugin'
import { editorViewCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js'
const props = defineProps({
docType: { type: String, default: 'text' },
docType: { type: String, default: 'txt' },
docName: { type: String, default: 'document.txt' },
uploadTime: { type: String, default: '' },
initialContent: { type: String, default: '' }
content: { type: String, default: '' },
collapsed: { type: Boolean, default: false },
resolveSuggestionRequest: { type: Function, default: null },
onUpdateContent: { type: Function, default: null },
onUpdateCollapsed: { type: Function, default: null },
onDelete: { type: Function, default: null },
})
const emit = defineEmits(['update:content', 'delete'])
const editorRoot = ref(null)
const isCollapsed = ref(false)
const collapsedState = ref(Boolean(props.collapsed))
const currentContent = ref(props.content || '')
let crepe = null
let internalChangeTimer = null
let syncTimer = null
let applyingExternalContent = false
const displayTime = computed(() => {
if (!props.uploadTime) return '刚上传'
const date = new Date(props.uploadTime)
if (Number.isNaN(date.getTime())) return '刚上传'
return date.toLocaleString('zh-CN', { hour12: false })
})
const typeLabel = computed(() => {
if (props.docType === 'docx') return 'DOCX'
if (props.docType === 'pptx') return 'PPTX'
if (props.docType === 'pdf') return 'PDF'
return 'TXT'
})
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
const downloadDoc = () => {
if (!crepe) return
crepe.getMarkdown().then(markdown => {
const blob = new Blob([markdown], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.docName
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
})
collapsedState.value = !collapsedState.value
props.onUpdateCollapsed?.(collapsedState.value)
}
const syncContent = () => {
if (!crepe) return
if (internalChangeTimer) clearTimeout(internalChangeTimer)
internalChangeTimer = setTimeout(async () => {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = setTimeout(async () => {
if (!crepe || applyingExternalContent) return
const markdown = await crepe.getMarkdown()
emit('update:content', markdown)
currentContent.value = markdown
props.onUpdateContent?.(markdown)
}, 120)
}
const syncExternalContent = async (nextValue) => {
if (!crepe) {
currentContent.value = nextValue || ''
return
}
if ((nextValue || '') === currentContent.value) return
applyingExternalContent = true
try {
crepe.editor.action(replaceAll(nextValue || ''))
currentContent.value = nextValue || ''
} finally {
applyingExternalContent = false
}
}
watch(() => props.content, (nextValue) => {
void syncExternalContent(nextValue || '')
})
watch(() => props.collapsed, (nextValue) => {
collapsedState.value = Boolean(nextValue)
})
onMounted(async () => {
if (!editorRoot.value) return
crepe = new Crepe({
root: editorRoot.value,
defaultValue: props.initialContent || '',
defaultValue: props.content || '',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.ListCheck]: true,
},
config: { showLineNumber: false }
config: {
showLineNumber: false,
},
})
crepe.editor.config(ctx => {
crepe.editor.config((ctx) => {
ctx.set(copilotConfigCtx.key, {
fetchSuggestion,
debounceMs: 1000
fetchSuggestion: async (prefix, suffix, languageId, signal) => {
const payload = props.resolveSuggestionRequest
? await props.resolveSuggestionRequest({ prefix, suffix, languageId })
: { prefix, suffix, languageId, blocked: false }
if (payload?.blocked) return ''
return fetchSuggestion(payload?.prefix ?? prefix, payload?.suffix ?? suffix, payload?.languageId ?? languageId, signal)
},
debounceMs: 900,
})
})
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
await crepe.create()
crepe.on(listener => {
crepe.on((listener) => {
listener.updated(() => {
syncContent()
})
})
crepe.editor.action(ctx => {
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
setCopilotEnabled(view, true)
})
})
watch(() => props.initialContent, (newVal) => {
if (crepe && newVal !== undefined) {
crepe.editor.action(ctx => {
const view = ctx.get(editorViewCtx)
const currentPos = view.state.selection.from
view.dispatch(view.state.tr.insertText(newVal))
})
}
})
onUnmounted(() => {
if (internalChangeTimer) clearTimeout(internalChangeTimer)
if (syncTimer) {
clearTimeout(syncTimer)
syncTimer = null
}
if (crepe) {
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
clearGhostSuggestion(view)
})
crepe.destroy()
crepe = null
}
})
defineExpose({
getContent: () => crepe ? crepe.getMarkdown() : Promise.resolve(''),
getEditor: () => crepe
})
</script>
<style scoped>
.doc-block-crepe {
margin: 12px 0;
border-radius: 8px;
position: relative;
margin: 14px 0;
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
border-radius: 18px;
overflow: hidden;
background: var(--crepe-color-surface-low);
border: 1px solid var(--panel-border);
}
.doc-block-crepe.collapsed .doc-editor {
display: none;
background: linear-gradient(180deg, color-mix(in srgb, var(--panel-bg) 82%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface-low) 88%, transparent) 100%);
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(14px);
}
.doc-header {
display: flex;
display: grid;
grid-template-columns: 4px 24px minmax(0, 1fr) auto;
align-items: center;
padding: 10px 12px;
background: var(--crepe-color-surface);
border-bottom: 1px solid var(--panel-border);
gap: 10px;
gap: 12px;
padding: 12px 14px;
background: linear-gradient(135deg, color-mix(in srgb, var(--btn-bg) 76%, transparent) 0%, color-mix(in srgb, var(--crepe-color-surface) 78%, transparent) 100%);
border-bottom: 1px solid color-mix(in srgb, var(--panel-border) 76%, transparent);
}
.doc-accent {
width: 4px;
height: 36px;
border-radius: 999px;
background: linear-gradient(180deg, #4f8cff 0%, #7dc1ff 100%);
box-shadow: 0 0 18px rgba(79, 140, 255, 0.32);
}
.doc-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--crepe-color-primary);
color: var(--btn-fg);
}
.doc-meta {
min-width: 0;
}
.doc-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--crepe-color-on-surface);
font-weight: 600;
color: var(--app-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-subline {
margin-top: 2px;
font-size: 11px;
color: var(--muted-text);
}
.doc-actions {
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
}
.action-btn {
width: 30px;
height: 30px;
border: 1px solid color-mix(in srgb, var(--panel-border) 72%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--btn-bg) 84%, transparent);
color: var(--btn-fg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
color: var(--crepe-color-on-surface-variant);
cursor: pointer;
border-radius: 4px;
opacity: 0.7;
transition: transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease;
}
.action-btn:hover {
background: var(--crepe-color-hover);
opacity: 1;
transform: translateY(-1px);
background: var(--btn-hover-bg);
border-color: var(--btn-hover-bg);
color: var(--btn-hover-fg);
}
.action-btn-danger:hover {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.22);
color: #dc2626;
}
.doc-editor {
padding: 8px;
background: var(--crepe-color-surface-low);
min-height: 120px;
max-height: 400px;
overflow-y: auto;
padding: 10px 12px 12px;
}
.inner-crepe {
width: 100%;
height: 100%;
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--crepe-color-background) 78%, transparent);
border: 1px solid color-mix(in srgb, var(--panel-border) 68%, transparent);
}
.inner-crepe :deep(.milkdown) {
background: transparent !important;
}
.inner-crepe :deep(.milkdown__main),
.inner-crepe :deep(.milkdown__editor) {
margin: 0 !important;
padding: 0 !important;
}
.inner-crepe :deep(.ProseMirror) {
min-height: 80px;
padding: 8px !important;
min-height: 92px;
padding: 10px 12px 14px !important;
font-size: 14px !important;
line-height: 1.7;
}
.inner-crepe :deep(.ProseMirror h1),
.inner-crepe :deep(.ProseMirror h2),
.inner-crepe :deep(.ProseMirror h3),
.inner-crepe :deep(.ProseMirror p),
.inner-crepe :deep(.ProseMirror li),
.inner-crepe :deep(.ProseMirror blockquote),
.inner-crepe :deep(.ProseMirror code) {
font-size: inherit;
}
</style>
+320 -81
View File
@@ -35,8 +35,9 @@
<button
type="button"
class="action-btn"
:class="{ 'force-disabled': isDocUploadDisabled }"
:aria-label="t('uploadFile')"
:title="t('uploadFile')"
:title="docUploadButtonTitle"
@click="triggerFileUpload"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -45,7 +46,7 @@
</svg>
<span class="btn-tooltip">{{ t('uploadFile') }}</span>
</button>
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept="image/*,.doc,.docx,.ppt,.pptx,.pdf,.zip,.txt,.json" style="display:none">
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept=".txt,.docx,.pptx,.pdf,text/plain,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" style="display:none">
<button
type="button"
@@ -63,21 +64,29 @@
</button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
<button
type="button"
class="action-btn"
:aria-label="t('exportMd')"
:title="t('exportMd')"
@click="exportMarkdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="btn-tooltip">{{ t('exportMd') }}</span>
</button>
<div class="export-btn-wrapper">
<button
type="button"
class="action-btn"
:aria-label="t('exportMd')"
:title="t('exportMd')"
@click="toggleExportDropdown"
@contextmenu.prevent="toggleExportDropdown"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
<path d="m19 9-4 4-4-4"/>
</svg>
<span class="btn-tooltip">{{ t('exportMd') }}</span>
</button>
<div v-if="showExportDropdown" class="export-dropdown">
<button type="button" @click="() => { exportMarkdown(); showExportDropdown = false; }">{{ t('exportMd') }}</button>
<button type="button" @click="() => { exportDocx(); showExportDropdown = false; }">{{ t('exportDocx') }}</button>
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
</div>
</div>
<div class="image-btn-wrapper">
<button
type="button"
@@ -154,12 +163,14 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { docBlockNode, docBlockRemark, docBlockView } from '../plugins/docBlockPlugin'
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js'
import { OCR_URL, EXPORT_PDF_URL } from '../utils/config.js'
import { convertFileToMarkdown } from '../utils/convert.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
import { DOC_BLOCK_NODE_TYPE, buildLegacyDocBlock, getDocTypeFromFilename, isSupportedDocFile, transformDocBlockMarkdownForClipboard, transformLegacyDocBlocksForExport, transformSpecialDocBlocksToLegacy } from '../utils/docBlock.js'
const emit = defineEmits(['update:markdown'])
const settings = useSettingsStore()
@@ -174,15 +185,18 @@ const cameraInputRef = ref(null)
const aiEnabled = ref(true)
const contentSize = ref(0)
const showImageDropdown = ref(false)
const showExportDropdown = ref(false)
const showUrlDialog = ref(false)
const imageUrl = ref('')
const canUndo = ref(false)
const canRedo = ref(false)
const isDocUploadDisabled = ref(false)
const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo')
const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera')
const API_KEY = 'your-secret-key-here'
const supportsCameraCapture = computed(() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
@@ -192,47 +206,111 @@ const aiButtonLabel = computed(() => {
if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI')
})
const docUploadButtonTitle = computed(() => {
if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
return t('uploadFile')
})
let crepe = null
let markdownSyncTimer = null
let rootResizeObserver = null
let editorCopyHandler = null
const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const CONVERT_EXT_RE = /\.(docx?|pptx?|pdf|zip)$/i
const TEXT_EXT_RE = /\.(txt|json)$/i
const TEXT_MIME_TYPES = new Set(['text/plain', 'application/json'])
const CONVERT_EXT_RE = /\.(docx|pptx|pdf)$/i
const TEXT_EXT_RE = /\.txt$/i
const TEXT_MIME_TYPES = new Set(['text/plain'])
const CONVERT_MIME_TYPES = new Set([
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf',
'application/zip',
'application/x-zip-compressed',
])
let lastInitialMarkdown = initialMarkdown.value
let lastInitialMarkdown = transformSpecialDocBlocksToLegacy(initialMarkdown.value)
const normalizeTrailingWhitespace = (value) => (value || '').replace(/\s+$/, '')
const padTimePart = (value) => String(value).padStart(2, '0')
const createExportName = () => {
const now = new Date()
const datePart = `${now.getFullYear()}${padTimePart(now.getMonth() + 1)}${padTimePart(now.getDate())}`
const timePart = `${padTimePart(now.getHours())}${padTimePart(now.getMinutes())}${padTimePart(now.getSeconds())}`
return `save${datePart}${timePart}`
}
const buildDocxBlob = async (markdown) => {
const { Document, Packer, Paragraph, HeadingLevel } = await import('docx')
const children = []
for (const line of markdown.split('\n')) {
if (line.startsWith('# ')) {
children.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
continue
}
if (line.startsWith('## ')) {
children.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
continue
}
if (line.startsWith('### ')) {
children.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
continue
}
if (line.startsWith('---')) {
children.push(new Paragraph({ text: '----------' }))
continue
}
children.push(line.trim() === '' ? new Paragraph({}) : new Paragraph({ text: line }))
}
return Packer.toBlob(new Document({ sections: [{ properties: {}, children }] }))
}
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
const getExportMarkdown = async () => {
if (!crepe) {
throw new Error('编辑器未初始化,请稍后重试')
}
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
clearCurrentSuggestion(view)
})
const markdown = await crepe.getMarkdown()
return transformLegacyDocBlocksForExport(markdown)
}
const syncInitialMarkdown = async (nextValue) => {
if (!crepe) {
lastInitialMarkdown = nextValue
lastInitialMarkdown = transformSpecialDocBlocksToLegacy(nextValue)
return
}
const normalizedNextValue = transformSpecialDocBlocksToLegacy(nextValue)
try {
const current = await crepe.getMarkdown()
const normalizedCurrent = normalizeTrailingWhitespace(current)
const normalizedLast = normalizeTrailingWhitespace(lastInitialMarkdown)
if (!normalizedCurrent || normalizedCurrent === normalizedLast) {
crepe.editor.action(replaceAll(nextValue))
crepe.editor.action(replaceAll(normalizedNextValue))
}
} catch {
// Ignore sync errors
} finally {
lastInitialMarkdown = nextValue
lastInitialMarkdown = normalizedNextValue
}
}
@@ -338,6 +416,58 @@ const updateHistoryState = (view) => {
canRedo.value = redoDepth(view.state) > 0
}
const serializeSelectionToMarkdown = (view, from, to) => {
const state = view.state
const slice = state.doc.slice(from, to)
const doc = state.schema.topNodeType.createAndFill(undefined, slice.content)
if (!doc) return state.doc.textBetween(from, to, '\n', '\n')
return crepe?.editor?.action((ctx) => {
const serializer = ctx.get(serializerCtx)
return serializer(doc)
}) || state.doc.textBetween(from, to, '\n', '\n')
}
const selectionIncludesDocBlock = (state) => {
const { from, to } = state.selection
let hasDocBlock = false
state.doc.nodesBetween(from, to, (node) => {
if (node.type?.name === DOC_BLOCK_NODE_TYPE) {
hasDocBlock = true
return false
}
return true
})
return hasDocBlock
}
const getCursorContext = (view) => {
const { $from } = view.state.selection
let inDocBlock = false
let fenceLanguage = ''
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth)
const typeName = node.type?.name || ''
if (typeName === DOC_BLOCK_NODE_TYPE) {
inDocBlock = true
break
}
if (typeName === 'code_block' || typeName === 'codeBlock' || typeName === 'code_fence' || typeName === 'fence') {
fenceLanguage = String(node.attrs?.language || node.attrs?.lang || node.attrs?.info || '').trim().toLowerCase()
break
}
}
const disabledByFence = fenceLanguage === 'mermaid' || fenceLanguage === 'tex' || fenceLanguage === 'latex' || fenceLanguage === 'katex'
return {
disabled: inDocBlock || disabledByFence,
inDocBlock,
fenceLanguage,
}
}
const refreshDocUploadState = (view) => {
isDocUploadDisabled.value = getCursorContext(view).disabled
}
const runHistoryCommand = (command) => {
if (!crepe) return
crepe.editor.action((ctx) => {
@@ -480,6 +610,15 @@ const prepareImageFile = async (file) => {
}
onMounted(async () => {
document.addEventListener('click', (e) => {
if (!e.target.closest('.export-btn-wrapper')) {
showExportDropdown.value = false
}
if (!e.target.closest('.image-btn-wrapper')) {
showImageDropdown.value = false
}
})
if (!root.value) throw new Error('root.value is null')
updateEditorTailSpace()
if (typeof ResizeObserver !== 'undefined') {
@@ -491,7 +630,7 @@ onMounted(async () => {
crepe = new Crepe({
root: root.value,
defaultValue: initialMarkdown.value || '',
defaultValue: transformSpecialDocBlocksToLegacy(initialMarkdown.value || ''),
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
@@ -547,6 +686,9 @@ onMounted(async () => {
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
crepe.editor.use(docBlockRemark)
crepe.editor.use(docBlockNode)
crepe.editor.use(docBlockView)
await crepe.create()
@@ -557,6 +699,7 @@ onMounted(async () => {
syncObjectUrls(doc)
refreshSizeAndLimit(ctx)
updateHistoryState(view)
refreshDocUploadState(view)
scheduleMarkdownSync()
})
})
@@ -566,32 +709,79 @@ onMounted(async () => {
setCopilotEnabled(view, aiEnabled.value)
refreshSizeAndLimit(ctx)
updateHistoryState(view)
refreshDocUploadState(view)
const editorDom = view.dom
editorCopyHandler = (event) => {
const state = view.state
if (!selectionIncludesDocBlock(state)) return
const { from, to } = state.selection
const rawMarkdown = serializeSelectionToMarkdown(view, from, to)
const clipboardMarkdown = transformDocBlockMarkdownForClipboard(rawMarkdown || '')
if (!clipboardMarkdown) return
event.preventDefault()
event.clipboardData?.setData('text/plain', clipboardMarkdown)
}
editorDom.addEventListener('copy', editorCopyHandler)
})
scheduleMarkdownSync()
})
const exportMarkdown = async () => {
if (!crepe) return
try {
const markdown = await getExportMarkdown()
const exportName = createExportName()
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
downloadBlob(blob, `${exportName}.md`)
} catch (error) {
console.error('Markdown export failed:', error)
alert(`Markdown 导出失败: ${error.message}`)
}
}
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
clearCurrentSuggestion(view)
})
const markdown = await crepe.getMarkdown()
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
a.href = url
a.download = `save${datePart}${timePart}.md`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
const exportDocx = async () => {
try {
console.log('Exporting DOCX...')
const markdown = await getExportMarkdown()
const blob = await buildDocxBlob(markdown)
const exportName = createExportName()
downloadBlob(blob, `${exportName}.docx`)
console.log('DOCX export completed')
} catch (error) {
console.error('DOCX export failed:', error)
alert(`DOCX导出失败: ${error.message}`)
}
}
const exportPdf = async () => {
try {
console.log('Exporting PDF via DOCX...')
const markdown = await getExportMarkdown()
const docxBlob = await buildDocxBlob(markdown)
const exportName = createExportName()
const formData = new FormData()
formData.append('file', docxBlob, `${exportName}.docx`)
const res = await fetch(EXPORT_PDF_URL, {
method: 'POST',
headers: {
'X-API-Key': API_KEY,
},
body: formData,
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
const pdfBlob = await res.blob()
downloadBlob(pdfBlob, `${exportName}.pdf`)
console.log('PDF export completed successfully')
alert('PDF导出成功!')
} catch (error) {
console.error('PDF export failed:', error)
alert(`PDF导出失败: ${error.message}`)
}
}
const triggerUpload = () => {
@@ -622,7 +812,7 @@ const handleFileUpload = async (event) => {
try {
const text = await file.text()
if (crepe && crepe.editor) {
crepe.editor.action(replaceAll(text))
crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
}
} catch {
// File upload error, ignore
@@ -647,6 +837,12 @@ const toggleAI = async () => {
const toggleImageDropdown = () => {
showImageDropdown.value = !showImageDropdown.value
showExportDropdown.value = false
}
const toggleExportDropdown = () => {
showExportDropdown.value = !showExportDropdown.value
showImageDropdown.value = false
}
const triggerImageUpload = () => {
@@ -689,13 +885,14 @@ const insertMarkdownAtCursor = (markdown) => {
})
}
const buildCodeBlock = (file, text) => {
const name = (file?.name || '').toLowerCase()
const lang = name.endsWith('.json') ? 'json' : 'text'
return `\n\`\`\`${lang}\n${text}\n\`\`\`\n`
const insertDocBlockAtCursor = (attrs) => {
if (!crepe) return
const markdown = buildLegacyDocBlock(attrs)
insertMarkdownAtCursor(`\n${markdown}\n`)
}
const triggerFileUpload = () => {
if (isDocUploadDisabled.value) return
uploadFileInputRef.value?.click()
}
@@ -704,42 +901,44 @@ const handleUploadFile = async (event) => {
const file = input.files?.[0]
if (!file) return
const convertible = isConvertibleFile(file)
try {
if (isImageFile(file)) {
const objectUrl = await prepareImageFile(file)
if (objectUrl) {
clearCurrentGhost()
insertImageAtCursor(objectUrl)
}
if (!isSupportedDocFile(file)) {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
return
}
if (isDocUploadDisabled.value || !crepe) {
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
return
}
const docType = getDocTypeFromFilename(file.name)
let content = ''
if (isTextFile(file)) {
const text = await file.text()
clearCurrentGhost()
insertMarkdownAtCursor(buildCodeBlock(file, text))
content = await file.text()
} else if (isConvertibleFile(file)) {
content = await convertFileToMarkdown(file)
} else {
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
return
}
if (convertible) {
const markdown = await convertFileToMarkdown(file)
if (!markdown) {
throw new Error('No markdown returned')
}
clearCurrentGhost()
insertMarkdownAtCursor(markdown)
return
if (!content) {
throw new Error('文档解析结果为空')
}
warnUnsupportedInsertType()
clearCurrentGhost()
insertDocBlockAtCursor({
docType,
docName: file.name || `document.${docType}`,
uploadTime: new Date().toISOString(),
collapsed: false,
content,
})
} catch (e) {
const message = e instanceof Error ? e.message : ''
if (convertible) {
warnConvertError(message)
} else {
warnUploadError(message)
}
warnConvertError(message)
} finally {
input.value = ''
}
@@ -788,6 +987,12 @@ onUnmounted(() => {
clearAllOcrCache()
if (crepe) {
if (editorCopyHandler) {
crepe.editor.action((ctx) => {
ctx.get(editorViewCtx).dom.removeEventListener('copy', editorCopyHandler)
})
editorCopyHandler = null
}
crepe.destroy()
crepe = null
}
@@ -799,7 +1004,6 @@ onUnmounted(() => {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.history-buttons {
@@ -850,7 +1054,8 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
z-index: 99999;
transform: translateZ(0);
}
.action-btn {
@@ -977,6 +1182,40 @@ onUnmounted(() => {
background: var(--crepe-color-hover);
}
.export-btn-wrapper {
position: relative;
}
.export-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
box-shadow: var(--panel-shadow);
overflow: hidden;
z-index: 10000;
min-width: 160px;
}
.export-dropdown button {
display: block;
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: left;
cursor: pointer;
font-size: 14px;
color: var(--app-text);
}
.export-dropdown button:hover {
background: var(--crepe-color-hover);
}
.url-dialog-overlay {
position: fixed;
top: 0;
+249
View File
@@ -0,0 +1,249 @@
import { createApp, reactive } from 'vue'
import { serializerCtx } from '@milkdown/kit/core'
import { $node, $remark, $view } from '@milkdown/kit/utils'
import type { Node as ProseNode, Schema } from '@milkdown/prose/model'
import type { EditorView, NodeView } from '@milkdown/prose/view'
import DocBlockCrepe from '../components/DocBlockCrepe.vue'
import {
DOC_BLOCK_FENCE_LANG,
DOC_BLOCK_NODE_TYPE,
DOC_CONTEXT_LIMIT,
buildLegacyDocBlock,
buildDocContextFence,
normalizeDocType,
parseLegacyDocBlock,
parseDocBlockValue,
stripDocBlockMarkdown,
} from '../utils/docBlock.js'
function serializeRangeToMarkdown(
doc: ProseNode,
from: number,
to: number,
schema: Schema,
serializer: (content: ProseNode) => string
): string {
if (from >= to) return ''
const slice = doc.slice(from, to)
if (slice.content.size <= 0) return ''
const sliceDoc = schema.topNodeType.createAndFill(undefined, slice.content)
return sliceDoc ? serializer(sliceDoc) : doc.textBetween(from, to, '\n', '\n')
}
function buildDocContext(doc: ProseNode, excludePos?: number) {
const blocks: string[] = []
doc.descendants((node, pos) => {
if (node.type.name !== DOC_BLOCK_NODE_TYPE) return true
if (excludePos !== undefined && pos === excludePos) return false
blocks.push(
buildDocContextFence({
docType: node.attrs.docType,
content: node.attrs.content,
})
)
return false
})
return blocks.join('\n\n')
}
class DocBlockNodeView implements NodeView {
node: ProseNode
view: EditorView
getPos: () => number | undefined
dom: HTMLElement
app: ReturnType<typeof createApp> | null = null
props: Record<string, any>
serializer: (content: ProseNode) => string
constructor(node: ProseNode, view: EditorView, getPos: () => number | undefined, serializer: (content: ProseNode) => string) {
this.node = node
this.view = view
this.getPos = getPos
this.serializer = serializer
this.dom = document.createElement('div')
this.dom.className = 'doc-block-node-view'
this.props = reactive({
docType: node.attrs.docType,
docName: node.attrs.docName,
uploadTime: node.attrs.uploadTime,
content: node.attrs.content,
collapsed: node.attrs.collapsed,
onUpdateContent: (content: string) => this.updateAttrs({ content }),
onUpdateCollapsed: (collapsed: boolean) => this.updateAttrs({ collapsed }),
onDelete: () => this.deleteNode(),
resolveSuggestionRequest: (payload: { prefix: string; suffix: string; languageId: string }) => this.resolveSuggestionRequest(payload),
})
this.mount()
}
mount() {
this.app = createApp(DocBlockCrepe, this.props)
this.app.mount(this.dom)
}
getPosValue() {
const pos = this.getPos()
return typeof pos === 'number' ? pos : undefined
}
updateAttrs(patch: Record<string, any>) {
const pos = this.getPosValue()
if (pos === undefined) return
const nextAttrs = { ...this.node.attrs, ...patch }
this.view.dispatch(this.view.state.tr.setNodeMarkup(pos, undefined, nextAttrs))
}
deleteNode() {
const pos = this.getPosValue()
if (pos === undefined) return
const tr = this.view.state.tr.delete(pos, pos + this.node.nodeSize).scrollIntoView()
this.view.dispatch(tr)
this.view.focus()
}
resolveSuggestionRequest(payload: { prefix: string; suffix: string; languageId: string }) {
const pos = this.getPosValue()
if (pos === undefined) return payload
const doc = this.view.state.doc
const schema = this.view.state.schema
const before = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, 0, pos, schema, this.serializer))
const after = stripDocBlockMarkdown(serializeRangeToMarkdown(doc, pos + this.node.nodeSize, doc.content.size, schema, this.serializer))
const docContext = buildDocContext(doc, pos)
const mergedPrefix = [docContext, before, payload.prefix].filter(Boolean).join('\n\n')
const mergedSuffix = [payload.suffix, after].filter(Boolean).join('\n\n')
if (mergedPrefix.length + mergedSuffix.length > DOC_CONTEXT_LIMIT) {
return {
prefix: mergedPrefix.slice(0, DOC_CONTEXT_LIMIT),
suffix: '',
languageId: payload.languageId,
blocked: true,
}
}
return {
prefix: mergedPrefix,
suffix: mergedSuffix,
languageId: payload.languageId,
blocked: false,
}
}
update(node: ProseNode) {
if (node.type !== this.node.type) return false
this.node = node
this.props.docType = node.attrs.docType
this.props.docName = node.attrs.docName
this.props.uploadTime = node.attrs.uploadTime
this.props.content = node.attrs.content
this.props.collapsed = node.attrs.collapsed
return true
}
stopEvent(event: Event) {
const target = event.target as Node | null
return Boolean(target && this.dom.contains(target))
}
ignoreMutation() {
return true
}
destroy() {
this.app?.unmount()
this.app = null
}
}
function visitChildren(node: any, visitor: (child: any) => any) {
if (!node || !Array.isArray(node.children)) return
node.children = node.children.map((child: any) => {
const next = visitor(child)
if (next && next !== child) return next
visitChildren(child, visitor)
return child
})
}
export const docBlockRemark = $remark('docBlockRemark', () => () => {
return (tree: any) => {
visitChildren(tree, (node) => {
if (node?.type === 'code' && node.lang === DOC_BLOCK_FENCE_LANG) {
return {
type: 'docBlock',
value: String(node.value || ''),
sourceType: 'code',
}
}
if (node?.type === 'html' && typeof node.value === 'string' && node.value.includes('<doc_type=')) {
return {
type: 'docBlock',
value: String(node.value || ''),
sourceType: 'html',
}
}
return node
})
}
})
export const docBlockNode = $node(DOC_BLOCK_NODE_TYPE, () => ({
group: 'block',
atom: true,
isolating: true,
selectable: true,
draggable: false,
marks: '',
attrs: {
docType: { default: 'txt' },
docName: { default: 'document.txt' },
uploadTime: { default: '' },
content: { default: '' },
collapsed: { default: false },
},
parseDOM: [
{
tag: 'div[data-doc-block="true"]',
getAttrs: (dom) => ({
docType: normalizeDocType((dom as HTMLElement).getAttribute('data-doc-type') || ''),
docName: (dom as HTMLElement).getAttribute('data-doc-name') || 'document.txt',
uploadTime: (dom as HTMLElement).getAttribute('data-doc-upload-time') || '',
collapsed: ((dom as HTMLElement).getAttribute('data-doc-collapsed') || '') === 'true',
content: '',
}),
},
],
toDOM: (node) => [
'div',
{
'data-doc-block': 'true',
'data-doc-type': node.attrs.docType,
'data-doc-name': node.attrs.docName,
'data-doc-upload-time': node.attrs.uploadTime,
'data-doc-collapsed': String(Boolean(node.attrs.collapsed)),
},
],
parseMarkdown: {
match: (node) => node.type === 'docBlock',
runner: (state, node, type) => {
const attrs = node.sourceType === 'code'
? parseDocBlockValue(String(node.value || ''))
: parseLegacyDocBlock(String(node.value || ''))
if (!attrs) return
state.addNode(type, attrs)
},
},
toMarkdown: {
match: (node) => node.type.name === DOC_BLOCK_NODE_TYPE,
runner: (state, node) => {
state.addNode('html', undefined, buildLegacyDocBlock(node.attrs))
},
},
}))
export const docBlockView = $view(docBlockNode, (ctx) => {
const serializer = ctx.get(serializerCtx)
return (node, view, getPos) => new DocBlockNodeView(node, view, getPos, serializer)
})
export function buildDocContextFromDoc(doc: ProseNode, excludePos?: number) {
return buildDocContext(doc, excludePos)
}
+4 -4
View File
@@ -273,7 +273,7 @@ body {
overflow: auto;
border-radius: 8px;
padding: 8px;
background: color-mix(in srgb, var(--crepe-color-background, #fff) 88%, transparent);
background: rgba(255, 255, 255, 0.88);
}
.mermaid-inner::-webkit-scrollbar {
@@ -315,7 +315,7 @@ body {
.mermaid-error {
padding: 12px 16px;
margin: 0;
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
background: rgba(220, 38, 38, 0.08);
border: 1px solid var(--danger-text, #dc2626);
border-radius: 6px;
color: var(--danger-text, #dc2626);
@@ -331,12 +331,12 @@ body {
:root[data-theme='dark'] .milkdown .cm-editor,
:root[data-theme='dark'] .milkdown .cm-scroller {
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
background-color: rgba(237, 237, 237, 0.86);
color: var(--crepe-color-on-surface);
}
:root[data-theme='dark'] .milkdown .cm-gutters {
background-color: color-mix(in srgb, var(--crepe-color-surface-low) 86%, transparent);
background-color: rgba(237, 237, 237, 0.86);
color: var(--crepe-color-on-surface-variant);
border-right-color: var(--panel-border);
}
+1
View File
@@ -5,3 +5,4 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteac
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || '/v1/export/pdf'
+1
View File
@@ -23,6 +23,7 @@ export async function convertFileToMarkdown(file) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here',
},
body: JSON.stringify({
file: base64,
+180
View File
@@ -0,0 +1,180 @@
export const DOC_BLOCK_NODE_TYPE = 'doc_block'
export const DOC_BLOCK_FENCE_LANG = 'llm-file'
export const DOC_CONTEXT_LIMIT = 32 * 1024
const IMAGE_MD_RE = /!\[[^\]]*]\([^)]+\)/g
const IMAGE_HTML_RE = /<img\b[^>]*>/gi
const HEADER_SEPARATOR = '\n---\n'
export function normalizeDocType(value = '') {
const lower = String(value || '').trim().toLowerCase()
if (lower === 'txt' || lower === 'text' || lower === 'plain') return 'txt'
if (lower === 'doc' || lower === 'docx' || lower === 'word') return 'docx'
if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx'
if (lower === 'pdf') return 'pdf'
return 'txt'
}
export function getDocTypeFromFilename(name = '') {
const lower = String(name || '').toLowerCase()
if (lower.endsWith('.docx')) return 'docx'
if (lower.endsWith('.pptx')) return 'pptx'
if (lower.endsWith('.pdf')) return 'pdf'
return 'txt'
}
export function isSupportedDocFile(file) {
if (!file) return false
const name = String(file.name || '').toLowerCase()
const type = String(file.type || '').toLowerCase()
return (
name.endsWith('.txt') ||
name.endsWith('.docx') ||
name.endsWith('.pptx') ||
name.endsWith('.pdf') ||
type === 'text/plain' ||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
type === 'application/pdf'
)
}
export function sanitizeDocContent(markdown = '') {
return String(markdown || '')
.replace(/\r\n?/g, '\n')
.replace(IMAGE_MD_RE, '')
.replace(IMAGE_HTML_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function quoteMeta(value = '') {
return JSON.stringify(String(value ?? ''))
}
function parseMetaLine(line = '') {
const idx = line.indexOf(':')
if (idx < 0) return null
const key = line.slice(0, idx).trim()
const rawValue = line.slice(idx + 1).trim()
if (!key) return null
try {
return [key, JSON.parse(rawValue)]
} catch {
return [key, rawValue]
}
}
function pickFence(content = '') {
const matches = String(content || '').match(/`{3,}/g) || []
const maxLen = matches.reduce((max, item) => Math.max(max, item.length), 2)
return '`'.repeat(maxLen + 1)
}
export function buildDocBlockValue(attrs = {}) {
const docType = normalizeDocType(attrs.docType)
const docName = String(attrs.docName || `document.${docType}`)
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
const collapsed = Boolean(attrs.collapsed)
const content = sanitizeDocContent(attrs.content || '')
return [
`type: ${quoteMeta(docType)}`,
`name: ${quoteMeta(docName)}`,
`uploadTime: ${quoteMeta(uploadTime)}`,
`collapsed: ${collapsed ? 'true' : 'false'}`,
'---',
content,
].join('\n')
}
export function parseDocBlockValue(raw = '') {
const normalized = String(raw || '').replace(/\r\n?/g, '\n')
const separatorIndex = normalized.indexOf(HEADER_SEPARATOR)
const headerText = separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : ''
const bodyText = separatorIndex >= 0 ? normalized.slice(separatorIndex + HEADER_SEPARATOR.length) : normalized
const attrs = {
docType: 'txt',
docName: 'document.txt',
uploadTime: '',
collapsed: false,
content: sanitizeDocContent(bodyText),
}
for (const line of headerText.split('\n')) {
const parsed = parseMetaLine(line)
if (!parsed) continue
const [key, value] = parsed
if (key === 'type') attrs.docType = normalizeDocType(value)
if (key === 'name' && value) attrs.docName = String(value)
if (key === 'uploadTime' && value) attrs.uploadTime = String(value)
if (key === 'collapsed') attrs.collapsed = value === true || value === 'true'
}
if (!attrs.docName) attrs.docName = `document.${attrs.docType}`
return attrs
}
export function buildDocBlockMarkdown(attrs = {}) {
const value = buildDocBlockValue(attrs)
const fence = pickFence(value)
return `${fence}${DOC_BLOCK_FENCE_LANG}\n${value}\n${fence}`
}
export function buildDocContextFence(attrs = {}) {
const docType = normalizeDocType(attrs.docType)
const content = sanitizeDocContent(attrs.content || '')
const fence = pickFence(content)
return `${fence}${docType}\n${content}\n${fence}`
}
export function buildLegacyDocBlock(attrs = {}) {
const docType = normalizeDocType(attrs.docType)
const docName = String(attrs.docName || `document.${docType}`)
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
const content = sanitizeDocContent(attrs.content || '')
return `<doc_type="${docType}" doc_name="${docName}" upload_time="${uploadTime}" collapsed="${Boolean(attrs.collapsed)}">\n${content}\n</doc_end>`
}
export function parseLegacyDocBlock(raw = '') {
const match = String(raw || '').match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
if (!match) return null
return {
docType: normalizeDocType(match[1]),
docName: match[2] || 'document.txt',
uploadTime: match[3] || '',
collapsed: match[4] === 'true',
content: sanitizeDocContent(match[5] || ''),
}
}
export function transformDocBlockMarkdownForClipboard(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
const replacedFence = String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
const attrs = parseDocBlockValue(value)
return `${prefix}${buildDocContextFence(attrs)}`
})
return replacedFence.replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
const attrs = parseLegacyDocBlock(full)
return attrs ? buildDocContextFence(attrs) : full
})
}
export function stripDocBlockMarkdown(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g
return String(markdown || '').replace(pattern, '$1').replace(/\n{3,}/g, '\n\n').trim()
}
export function transformLegacyDocBlocksForExport(markdown = '') {
return String(markdown || '').replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
const attrs = parseLegacyDocBlock(full)
return attrs ? buildDocBlockMarkdown(attrs) : full
})
}
export function transformSpecialDocBlocksToLegacy(markdown = '') {
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
return String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
const attrs = parseDocBlockValue(value)
return `${prefix}${buildLegacyDocBlock(attrs)}`
})
}