modified: backend/llm.py
modified: src/components/MilkdownEditor.vue modified: src/utils/config.js modified: src/utils/i18n.js
This commit is contained in:
@@ -36,24 +36,27 @@
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'force-disabled': isDocUploadDisabled }"
|
||||
:aria-label="t('uploadFile')"
|
||||
:title="docUploadButtonTitle"
|
||||
@click="triggerFileUpload"
|
||||
:aria-label="t('upload')"
|
||||
:title="uploadButtonTitle"
|
||||
@click="triggerUpload"
|
||||
>
|
||||
<svg width="20" height="20" 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"/>
|
||||
<rect x="7" y="11" width="8" height="6" rx="1" stroke-width="1.5"/>
|
||||
<circle cx="9" cy="13" r="0.8" fill="currentColor"/>
|
||||
<path d="M7 16l2-2 2 2" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('uploadFile') }}</span>
|
||||
<span class="btn-tooltip">{{ t('upload') }}</span>
|
||||
</button>
|
||||
<input type="file" ref="uploadFileInputRef" @change="handleUploadFile" accept=".txt,.json,.toml,.yaml,.yml,.docx,.pptx,.pdf,text/plain,application/json,text/yaml,text/x-yaml,application/x-yaml,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation" multiple style="display:none">
|
||||
|
||||
<input type="file" ref="uploadInputRef" @change="handleUpload" :accept="acceptAll" multiple style="display:none">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:aria-label="t('importMd')"
|
||||
:title="t('importMd')"
|
||||
@click="triggerUpload"
|
||||
@click="triggerImportMd"
|
||||
>
|
||||
<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"/>
|
||||
@@ -62,7 +65,7 @@
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('importMd') }}</span>
|
||||
</button>
|
||||
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
|
||||
<input type="file" ref="mdInputRef" @change="handleImportMd" accept=".md,text/markdown,text/x-markdown" style="display:none">
|
||||
|
||||
<div class="export-btn-wrapper">
|
||||
<button
|
||||
@@ -87,29 +90,6 @@
|
||||
<button type="button" @click="() => { exportPdf(); showExportDropdown = false; }">{{ t('exportPdf') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-btn-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:aria-label="t('uploadImg')"
|
||||
:title="t('uploadImg')"
|
||||
@click="toggleImageDropdown"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
<span class="btn-tooltip">{{ t('uploadImg') }}</span>
|
||||
</button>
|
||||
<div v-if="showImageDropdown" class="image-dropdown">
|
||||
<button v-if="supportsCameraCapture" type="button" @click="triggerCameraCapture">{{ cameraUploadLabel }}</button>
|
||||
<button type="button" @click="triggerImageUpload">{{ t('uploadImg') }}</button>
|
||||
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
|
||||
<input type="file" ref="cameraInputRef" @change="handleImageUpload" accept="image/*" capture="environment" style="display:none">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -161,22 +141,6 @@
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showUrlDialog" class="url-dialog-overlay" @click.self="showUrlDialog = false">
|
||||
<div class="url-dialog">
|
||||
<h3>{{ t('insertUrl') }}</h3>
|
||||
<input
|
||||
v-model="imageUrl"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
@keyup.enter="insertImageFromUrl"
|
||||
/>
|
||||
<div class="url-dialog-buttons">
|
||||
<button type="button" class="dialog-btn primary" @click="insertImageFromUrl">{{ t('insert') }}</button>
|
||||
<button type="button" class="dialog-btn" @click="showUrlDialog = false; imageUrl = ''">{{ t('cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadProgress" class="upload-progress-overlay">
|
||||
@@ -214,17 +178,12 @@ const t = (key) => settings.t[key]
|
||||
const initialMarkdown = computed(() => settings.initialMarkdown)
|
||||
|
||||
const root = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const uploadFileInputRef = ref(null)
|
||||
const imageInputRef = ref(null)
|
||||
const cameraInputRef = ref(null)
|
||||
const uploadInputRef = ref(null)
|
||||
const mdInputRef = ref(null)
|
||||
const aiEnabled = ref(true)
|
||||
const contentSize = ref(0)
|
||||
const showImageDropdown = ref(false)
|
||||
const showExportDropdown = ref(false)
|
||||
const showUrlDialog = ref(false)
|
||||
const showSizeTooltip = ref(false)
|
||||
const imageUrl = ref('')
|
||||
const canUndo = ref(false)
|
||||
const canRedo = ref(false)
|
||||
const isDocUploadDisabled = ref(false)
|
||||
@@ -233,20 +192,28 @@ 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 || ''
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua)
|
||||
})
|
||||
const aiButtonLabel = computed(() => {
|
||||
if (isOverLimit.value) return t('docTooLarge')
|
||||
return aiEnabled.value ? t('disableAI') : t('enableAI')
|
||||
})
|
||||
const docUploadButtonTitle = computed(() => {
|
||||
const uploadButtonTitle = computed(() => {
|
||||
if (isDocUploadDisabled.value) return t('uploadDocInBlockWarning') || '当前光标位置不能插入文件'
|
||||
return t('uploadFile')
|
||||
return t('upload')
|
||||
})
|
||||
const acceptAll = computed(() => {
|
||||
const types = [
|
||||
'.txt', '.json', '.toml', '.yaml', '.yml',
|
||||
'.docx', '.pptx', '.pdf',
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif', '.avif',
|
||||
'text/plain', 'application/json',
|
||||
'text/yaml', 'text/x-yaml', 'application/x-yaml',
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'image/*'
|
||||
]
|
||||
return types.join(',')
|
||||
})
|
||||
|
||||
let crepe = null
|
||||
@@ -644,10 +611,8 @@ onMounted(async () => {
|
||||
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')
|
||||
|
||||
@@ -817,39 +782,145 @@ const exportPdf = async () => {
|
||||
}
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInputRef.value?.click()
|
||||
if (isDocUploadDisabled.value) return
|
||||
uploadInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event) => {
|
||||
const handleUpload = async (event) => {
|
||||
const input = event.target
|
||||
const files = Array.from(input.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
const BATCH_LIMIT = 10
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
|
||||
if (files.length > BATCH_LIMIT) {
|
||||
alert(t('uploadBatchLimit') || `一次最多上传${BATCH_LIMIT}个文件`)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_FILE_SIZE / 1024 / 1024}MB限制`)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const imageFiles = files.filter(isImageFile)
|
||||
const docFiles = files.filter(f => !isImageFile(f))
|
||||
|
||||
if (docFiles.length > 0 && isDocUploadDisabled.value) {
|
||||
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const isSupported = isImageFile(file) || isSupportedDocFile(file)
|
||||
if (!isSupported) {
|
||||
alert(t('uploadFileTypeWarning') || '不支持的文件类型')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentGhost()
|
||||
|
||||
for (const file of imageFiles) {
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (objectUrl) {
|
||||
insertImageAtCursor(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (docFiles.length > 0) {
|
||||
uploadProgress.value = { current: 0, total: docFiles.length, filename: '' }
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let index = 0; index < docFiles.length; index++) {
|
||||
const file = docFiles[index]
|
||||
uploadProgress.value = { current: index + 1, total: docFiles.length, filename: file.name }
|
||||
|
||||
try {
|
||||
const docType = getDocTypeFromFilename(file.name)
|
||||
let content = ''
|
||||
|
||||
if (isTextFile(file)) {
|
||||
content = await file.text()
|
||||
} else if (isConvertibleFile(file)) {
|
||||
content = await convertFileToMarkdown(file)
|
||||
} else {
|
||||
throw new Error('不支持的文件类型')
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('文档解析结果为空')
|
||||
}
|
||||
|
||||
results.push({
|
||||
docType,
|
||||
docName: file.name || `document.${docType}`,
|
||||
content,
|
||||
index,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
errors.push({ filename: file.name, message })
|
||||
}
|
||||
}
|
||||
|
||||
uploadProgress.value = null
|
||||
|
||||
results.sort((a, b) => a.index - b.index)
|
||||
|
||||
const blocksToInsert = results.map(({ docType, docName, content }) => ({
|
||||
docType,
|
||||
docName,
|
||||
content,
|
||||
uploadTime: new Date().toISOString(),
|
||||
collapsed: false,
|
||||
}))
|
||||
|
||||
insertMultipleDocBlocks(blocksToInsert)
|
||||
|
||||
if (errors.length > 0) {
|
||||
const failCount = errors.length
|
||||
const errorMsgs = errors.map(e => `${e.filename}: ${e.message}`).join('\n')
|
||||
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
|
||||
}
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const triggerImportMd = () => {
|
||||
mdInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleImportMd = async (event) => {
|
||||
const input = event.target
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (isImageFile(file)) {
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (objectUrl) {
|
||||
clearCurrentGhost()
|
||||
insertImageAtCursor(objectUrl)
|
||||
}
|
||||
if (!isMarkdownFile(file)) {
|
||||
alert(t('uploadMdTypeWarning') || '仅支持 Markdown(.md)文件')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(file)) {
|
||||
warnUnsupportedUploadType()
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
if (crepe && crepe.editor) {
|
||||
crepe.editor.action(replaceAll(transformSpecialDocBlocksToLegacy(text)))
|
||||
}
|
||||
} catch {
|
||||
// File upload error, ignore
|
||||
// ignore
|
||||
}
|
||||
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
@@ -867,24 +938,8 @@ const toggleAI = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const toggleImageDropdown = () => {
|
||||
showImageDropdown.value = !showImageDropdown.value
|
||||
showExportDropdown.value = false
|
||||
}
|
||||
|
||||
const toggleExportDropdown = () => {
|
||||
showExportDropdown.value = !showExportDropdown.value
|
||||
showImageDropdown.value = false
|
||||
}
|
||||
|
||||
const triggerImageUpload = () => {
|
||||
showImageDropdown.value = false
|
||||
imageInputRef.value?.click()
|
||||
}
|
||||
|
||||
const triggerCameraCapture = () => {
|
||||
showImageDropdown.value = false
|
||||
cameraInputRef.value?.click()
|
||||
}
|
||||
|
||||
const insertImageAtCursor = (src) => {
|
||||
@@ -990,135 +1045,6 @@ const insertMultipleDocBlocks = (blocks) => {
|
||||
})
|
||||
}
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
if (isDocUploadDisabled.value) return
|
||||
uploadFileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleUploadFile = async (event) => {
|
||||
const input = event.target
|
||||
const files = Array.from(input.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
const BATCH_LIMIT = 10
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
|
||||
if (files.length > BATCH_LIMIT) {
|
||||
alert(t('uploadBatchLimit') || `一次最多上传${BATCH_LIMIT}个文件`)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(t('uploadSizeLimit') || `${file.name} 超过${MAX_FILE_SIZE / 1024 / 1024}MB限制`)
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!isSupportedDocFile(file)) {
|
||||
alert(t('uploadDocTypeWarning') || '仅支持 txt、docx、pptx、pdf 格式的文档')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocUploadDisabled.value || !crepe) {
|
||||
alert(t('uploadDocInBlockWarning') || '当前光标位置不能插入文件')
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const total = files.length
|
||||
uploadProgress.value = { current: 0, total, filename: '' }
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index]
|
||||
uploadProgress.value = { current: index + 1, total, filename: file.name }
|
||||
|
||||
try {
|
||||
const docType = getDocTypeFromFilename(file.name)
|
||||
let content = ''
|
||||
|
||||
if (isTextFile(file)) {
|
||||
content = await file.text()
|
||||
} else if (isConvertibleFile(file)) {
|
||||
content = await convertFileToMarkdown(file)
|
||||
} else {
|
||||
throw new Error('不支持的文件类型')
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('文档解析结果为空')
|
||||
}
|
||||
|
||||
results.push({
|
||||
docType,
|
||||
docName: file.name || `document.${docType}`,
|
||||
content,
|
||||
index,
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : ''
|
||||
errors.push({ filename: file.name, message })
|
||||
}
|
||||
}
|
||||
|
||||
uploadProgress.value = null
|
||||
clearCurrentGhost()
|
||||
|
||||
results.sort((a, b) => a.index - b.index)
|
||||
|
||||
const blocksToInsert = results.map(({ docType, docName, content }) => ({
|
||||
docType,
|
||||
docName,
|
||||
content,
|
||||
uploadTime: new Date().toISOString(),
|
||||
collapsed: false,
|
||||
}))
|
||||
|
||||
insertMultipleDocBlocks(blocksToInsert)
|
||||
|
||||
if (errors.length > 0) {
|
||||
const failCount = errors.length
|
||||
const errorMsgs = errors.map(e => `${e.filename}: ${e.message}`).join('\n')
|
||||
alert(`上传失败 ${failCount} 个文件:\n\n${errorMsgs}`)
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const handleImageUpload = async (event) => {
|
||||
const input = event.target
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const objectUrl = await prepareImageFile(file)
|
||||
if (!objectUrl) {
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
clearCurrentGhost()
|
||||
insertImageAtCursor(objectUrl)
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const insertImageFromUrl = () => {
|
||||
const url = imageUrl.value.trim()
|
||||
if (!url) return
|
||||
|
||||
insertImageAtCursor(url)
|
||||
imageUrl.value = ''
|
||||
showUrlDialog.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (markdownSyncTimer) {
|
||||
clearTimeout(markdownSyncTimer)
|
||||
|
||||
Reference in New Issue
Block a user