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:
2026-02-15 22:17:37 +08:00
parent 190bb2b756
commit 1e58c18bbc
7 changed files with 161 additions and 96 deletions
+50 -68
View File
@@ -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)
+10 -13
View File
@@ -9,7 +9,6 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT
const DEBUG = true
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
interface CopilotState {
@@ -334,13 +333,8 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
const doc = view.state.doc
const schema = view.state.schema
const overLimit = doc.content.size > SIZE_LIMIT
if (overLimit) {
setCopilotEnabled(view, false)
return
}
const baseSize = doc.content.size
const serializer = runtime.ctx.get(serializerCtx)
let prefixMarkdown = ''
let suffixMarkdown = ''
@@ -362,12 +356,15 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
}
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
const totalWithOcr = totalTextLen + ocrContextLen
const overLimit = totalWithOcr > SIZE_LIMIT
if (DEBUG) {
console.log('[Copilot] ===== LLM Request =====')
console.log('[Copilot] PREFIX:', requestPrefix)
console.log('[Copilot] SUFFIX:', suffixMarkdown)
console.log('[Copilot] ======================')
if (overLimit) {
setCopilotEnabled(view, false)
return
}
if (runtime.debounceTimer) {
+4 -10
View File
@@ -1,7 +1,6 @@
import { DEBUG, API_URL } from './config.js'
import { API_URL } from './config.js'
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
if (DEBUG) console.log('[Debug] fetchSuggestion called with prefix length:', prefix.length, 'suffix length:', suffix.length)
try {
const res = await fetch(apiUrl, {
method: 'POST',
@@ -10,7 +9,6 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
signal
})
if (DEBUG) console.log('[Debug] fetchSuggestion response status:', res.status)
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
@@ -18,7 +16,6 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
const reader = res.body?.getReader()
if (!reader) {
if (DEBUG) console.log('[Debug] No reader available')
throw new Error('No reader available')
}
@@ -40,23 +37,20 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
const data = JSON.parse(jsonStr)
if (data.content) {
text += data.content
if (DEBUG) console.log('[Debug] Added content:', data.content)
}
if (data.done || data.error) break
} catch (e) {
if (DEBUG) console.warn('[Debug] JSON parse error for:', jsonStr.substring(0, 50))
// skip invalid lines
}
}
}
if (DEBUG) console.log('[Debug] Final suggestion text:', text.substring(0, 100))
return text
} catch (e) {
if (e.name === 'AbortError') {
if (DEBUG) console.log('[Debug] Request aborted')
// ignore abort
} else {
if (DEBUG) console.error('[Debug] fetchSuggestion error:', e)
throw e
}
throw e
}
}
+17 -1
View File
@@ -1,6 +1,22 @@
const SIZE_LIMIT = 64 * 1024
const SIZE_LIMIT = 32 * 1024
export const IMAGE_SIZE_LIMIT = 100 * 1024 * 1024
const ocrCache = new Map()
const imageHashCache = new Map()
export async function calculateImageHash(imageBytes) {
const hashBuffer = await crypto.subtle.digest('SHA-256', imageBytes)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export function getOcrByHash(hash) {
return imageHashCache.get(hash) || ''
}
export function setOcrByHash(hash, text) {
imageHashCache.set(hash, text)
}
export function setOcrCache(filename, text) {
ocrCache.set(filename, text)