feat: add image hash-based OCR caching with 100MB size limit
- Implement SHA-256 image hashing to cache OCR results and avoid re-processing identical images - Add 100MB file size limit for image uploads with user-friendly error messages - Clear ghost suggestions when uploading new images to prevent interference - Optimize size limit calculation in copilot plugin to include OCR context - Remove debug logging from production code - Add image processing optimization plan document BREAKING CHANGE: Image upload size limit is now enforced at 100MB (previously unlimited)
This commit is contained in:
@@ -108,8 +108,8 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Selection } from '@milkdown/prose/state'
|
||||
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
|
||||
import { fetchSuggestion } from '../utils/api.js'
|
||||
import { DEBUG, OCR_URL } from '../utils/config.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache } from '../utils/ocrCache.js'
|
||||
import { OCR_URL } from '../utils/config.js'
|
||||
import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
|
||||
|
||||
const emit = defineEmits(['update:markdown'])
|
||||
|
||||
@@ -130,7 +130,6 @@ const aiButtonLabel = computed(() => {
|
||||
|
||||
let crepe = null
|
||||
let markdownSyncTimer = null
|
||||
let debugLogTimer = null
|
||||
const objectUrls = new Set()
|
||||
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
|
||||
|
||||
@@ -201,67 +200,24 @@ const scheduleMarkdownSync = () => {
|
||||
const markdown = await crepe.getMarkdown()
|
||||
emit('update:markdown', markdown)
|
||||
} catch (e) {
|
||||
if (DEBUG) console.error('[Markdown] Sync failed:', e)
|
||||
// sync error, ignore
|
||||
}
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const logDebugInfo = async () => {
|
||||
if (!crepe) return
|
||||
try {
|
||||
const markdown = await crepe.getMarkdown()
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const schema = view.state.schema
|
||||
const { from, to } = view.state.selection
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
let prefixMarkdown = '', suffixMarkdown = ''
|
||||
|
||||
try {
|
||||
// Prefix: 使用 slice 创建文档节点
|
||||
const prefixSlice = view.state.doc.slice(0, from)
|
||||
if (prefixSlice.content.size > 0) {
|
||||
const prefixDoc = schema.topNodeType.createAndFill(undefined, prefixSlice.content)
|
||||
if (prefixDoc) {
|
||||
prefixMarkdown = serializer(prefixDoc)
|
||||
}
|
||||
}
|
||||
if (!prefixMarkdown) {
|
||||
prefixMarkdown = view.state.doc.textBetween(0, from, '\n', '\n')
|
||||
}
|
||||
|
||||
// Suffix
|
||||
const suffixSlice = view.state.doc.slice(to)
|
||||
if (suffixSlice.content.size > 0) {
|
||||
const suffixDoc = schema.topNodeType.createAndFill(undefined, suffixSlice.content)
|
||||
if (suffixDoc) {
|
||||
suffixMarkdown = serializer(suffixDoc)
|
||||
}
|
||||
}
|
||||
if (!suffixMarkdown) {
|
||||
suffixMarkdown = view.state.doc.textBetween(to, view.state.doc.content.size, '\n', '\n')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Debug] Serializer error:', e)
|
||||
prefixMarkdown = view.state.doc.textBetween(0, from, '\n', '\n')
|
||||
suffixMarkdown = view.state.doc.textBetween(to, view.state.doc.content.size, '\n', '\n')
|
||||
}
|
||||
console.log('[Debug] ===== Document State =====')
|
||||
console.log('[Debug] PREFIX:', prefixMarkdown)
|
||||
console.log('[Debug] SUFFIX:', suffixMarkdown)
|
||||
console.log('[Debug] FULL MARKDOWN:', markdown)
|
||||
console.log('[Debug] ==========================')
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[Debug] Log failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const clearCurrentSuggestion = (view) => {
|
||||
clearGhostSuggestion(view)
|
||||
}
|
||||
|
||||
const performOCR = async (file, cacheKey) => {
|
||||
const clearCurrentGhost = () => {
|
||||
if (!crepe) return
|
||||
crepe.editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
clearGhostSuggestion(view)
|
||||
})
|
||||
}
|
||||
|
||||
const performOCR = async (file, cacheKey, imageHash = '') => {
|
||||
if (!aiEnabled.value) return
|
||||
|
||||
const reader = new FileReader()
|
||||
@@ -289,6 +245,9 @@ const performOCR = async (file, cacheKey) => {
|
||||
if (data.text) {
|
||||
setOcrCache(cacheKey, data.text)
|
||||
setOcrCache(file.name, data.text)
|
||||
if (imageHash) {
|
||||
setOcrByHash(imageHash, data.text)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OCR] Error:', e)
|
||||
@@ -298,10 +257,8 @@ const performOCR = async (file, cacheKey) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (DEBUG) console.log('[Debug] onMounted called')
|
||||
if (!root.value) throw new Error('root.value is null')
|
||||
|
||||
if (DEBUG) console.log('[Debug] Creating Crepe editor...')
|
||||
crepe = new Crepe({
|
||||
root: root.value,
|
||||
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
|
||||
@@ -318,10 +275,24 @@ onMounted(async () => {
|
||||
inlineEditConfirm: 'Escape'
|
||||
},
|
||||
[Crepe.Feature.ImageBlock]: {
|
||||
onUpload: (file) => {
|
||||
onUpload: async (file) => {
|
||||
if (file.size > IMAGE_SIZE_LIMIT) {
|
||||
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`)
|
||||
return null
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrls.add(objectUrl)
|
||||
performOCR(file, objectUrl)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const imageBytes = new Uint8Array(arrayBuffer)
|
||||
const imageHash = await calculateImageHash(imageBytes)
|
||||
const existingOcr = getOcrByHash(imageHash)
|
||||
if (!existingOcr) {
|
||||
performOCR(file, objectUrl, imageHash)
|
||||
} else {
|
||||
setOcrCache(objectUrl, existingOcr)
|
||||
setOcrCache(file.name, existingOcr)
|
||||
}
|
||||
clearCurrentGhost()
|
||||
return objectUrl
|
||||
}
|
||||
}
|
||||
@@ -358,9 +329,6 @@ onMounted(async () => {
|
||||
refreshSizeAndLimit(ctx)
|
||||
})
|
||||
scheduleMarkdownSync()
|
||||
debugLogTimer = setInterval(logDebugInfo, 20000)
|
||||
|
||||
if (DEBUG) console.log('[Debug] Crepe editor created with copilot plugin')
|
||||
})
|
||||
|
||||
const exportMarkdown = async () => {
|
||||
@@ -450,9 +418,27 @@ const handleImageUpload = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > IMAGE_SIZE_LIMIT) {
|
||||
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`)
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
objectUrls.add(objectUrl)
|
||||
performOCR(file, objectUrl)
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const imageBytes = new Uint8Array(arrayBuffer)
|
||||
const imageHash = await calculateImageHash(imageBytes)
|
||||
const existingOcr = getOcrByHash(imageHash)
|
||||
if (!existingOcr) {
|
||||
performOCR(file, objectUrl, imageHash)
|
||||
} else {
|
||||
setOcrCache(objectUrl, existingOcr)
|
||||
setOcrCache(file.name, existingOcr)
|
||||
}
|
||||
|
||||
clearCurrentGhost()
|
||||
insertImageAtCursor(objectUrl)
|
||||
|
||||
event.target.value = ''
|
||||
@@ -472,10 +458,6 @@ onUnmounted(() => {
|
||||
clearTimeout(markdownSyncTimer)
|
||||
markdownSyncTimer = null
|
||||
}
|
||||
if (debugLogTimer) {
|
||||
clearInterval(debugLogTimer)
|
||||
debugLogTimer = null
|
||||
}
|
||||
|
||||
for (const url of Array.from(objectUrls)) {
|
||||
revokeObjectUrl(url)
|
||||
|
||||
Reference in New Issue
Block a user