modified: backend/llm.py

modified:   src/components/MilkdownEditor.vue
	modified:   src/utils/config.js
	modified:   src/utils/i18n.js
This commit is contained in:
2026-04-05 15:10:23 +08:00
parent 9293d48c1b
commit 818baa349a
4 changed files with 163 additions and 230 deletions

View File

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

View File

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

View File

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

View File

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