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>