diff --git a/backend/llm.py b/backend/llm.py index 926051c..c69955e 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -16,8 +16,8 @@ VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b') # Timeouts in seconds (10 minutes for large model loading) COMPLETION_TIMEOUT = 600 -OCR_TIMEOUT = 120 -CONVERT_TIMEOUT = 60 +OCR_TIMEOUT = 600 +CONVERT_TIMEOUT = 600 client = ollama.AsyncClient(host=OLLAMA_HOST) logger = logging.getLogger("llm") diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index 5289e6d..6a15b69 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -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" > + + + - {{ t('uploadFile') }} + {{ t('upload') }} - - + + - +
-
- -
- - - -
-
- - - - - -
@@ -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) diff --git a/src/utils/config.js b/src/utils/config.js index 1c3cd9f..48819a1 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -1,6 +1,7 @@ export const DEBUG = import.meta.env.DEV -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteach.tech:8002' +const DEFAULT_API_BASE_URL = import.meta.env.DEV ? '' : 'https://api.imageteach.tech:8002' +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL 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` diff --git a/src/utils/i18n.js b/src/utils/i18n.js index 88c1ee5..927838d 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -33,6 +33,7 @@ export const translations = { exportMd: 'Export Markdown', exportDocx: 'Export DOCX', exportPdf: 'Export PDF', + upload: 'Upload', uploadImg: 'Upload Image', uploadFile: 'Upload File', uploadDoc: 'Upload Document', @@ -90,6 +91,7 @@ export const translations = { exportMd: '导出 Markdown', exportDocx: '导出 DOCX', exportPdf: '导出 PDF', + upload: '上传', uploadImg: '上传图片', uploadFile: '上传文件', uploadDoc: '上传文档', @@ -147,6 +149,7 @@ export const translations = { exportMd: 'Markdownをエクスポート', exportDocx: 'DOCXをエクスポート', exportPdf: 'PDFをエクスポート', + upload: 'アップロード', uploadImg: '画像をアップロード', uploadFile: 'Upload File', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', @@ -199,6 +202,7 @@ export const translations = { exportMd: 'Markdown 내보내기', exportDocx: 'DOCX 내보내기', exportPdf: 'PDF 내보내기', + upload: '업로드', uploadImg: '이미지 업로드', uploadFile: 'Upload File', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', @@ -248,6 +252,7 @@ export const translations = { exportMd: 'Markdown exportieren', exportDocx: 'DOCX exportieren', exportPdf: 'PDF exportieren', + upload: 'Hochladen', uploadImg: 'Bild hochladen', uploadFile: 'Upload File', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.', @@ -297,6 +302,7 @@ export const translations = { exportMd: 'Exporter Markdown', exportDocx: 'Exporter DOCX', exportPdf: 'Exporter PDF', + upload: 'Télécharger', uploadImg: 'Télécharger image', uploadFile: 'Upload File', uploadFileTypeWarning: 'Unsupported file type. Supported: doc/docx/ppt/pptx/pdf/zip, images, txt/json.',