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
Binary file not shown.
+1 -4
View File
@@ -46,7 +46,7 @@ Hard rules:
Do NOT repeat text that already appears at the start of SUFFIX. Do NOT repeat text that already appears at the start of SUFFIX.
3. Balanced length: 3. Balanced length:
Prefer concise but meaningful continuation, not ultra-short fragments. Prefer concise but meaningful continuation, not ultra-short fragments.
Default target is 20-120 characters and 1-3 lines for plain prose. Default target is 10-500 characters and 1-20 lines for plain prose.
You may be longer when structure requires it (lists, tables, code blocks, math blocks). You may be longer when structure requires it (lists, tables, code blocks, math blocks).
4. Avoid trivial output: 4. Avoid trivial output:
Do not output only punctuation or filler such as ".", ",", ";", ":". Do not output only punctuation or filler such as ".", ",", ";", ":".
@@ -89,6 +89,3 @@ Now produce the insertion.
Output:""" Output:"""
return prompt.strip() return prompt.strip()
+79
View File
@@ -0,0 +1,79 @@
# 图片处理优化计划
## 需求概述
1. 图片上传大小限制为100MB
2. 为每个图片做哈希,相同哈希不重复调用OCR
3. 上传图片时打断之前的ghost text
## 需要修改的文件
### 1. `src/utils/ocrCache.js` - 扩展OCR缓存模块
**新增功能:**
- 图片哈希缓存:`imageHashCache` Map,用于存储 `hash -> ocrText` 的映射
- 哈希计算函数:使用 `crypto.subtle.digest('SHA-256', imageBytes)` 计算哈希
- 哈希检查函数:在OCR前检查哈希是否已存在
- 100MB大小限制常量
```javascript
// 新增
export const IMAGE_SIZE_LIMIT = 100 * 1024 * 1024 // 100MB
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)
}
```
### 2. `src/components/MilkdownEditor.vue` - 修改图片上传逻辑
**修改点:**
1. **`handleImageUpload` 函数 (约第392行)**
- 添加文件大小检查,超过100MB则提示错误
- 计算图片哈希,检查是否已存在OCR结果
- 上传图片前调用 `clearGhostSuggestion` 打断现有ghost text
2. **`performOCR` 函数 (约第212行)**
- 接收哈希参数,OCR完成后存储到哈希缓存
3. **Milkdown `onUpload` 回调 (约第267行)**
- 同样添加大小限制和哈希检查
### 3. `src/plugins/copilotPlugin.ts` - 可能需要导出清除函数
- 确保 `clearGhostSuggestion` 可以被外部调用(目前已导出)
## 实现步骤
```mermaid
graph TD
A[用户上传图片] --> B{文件大小 <= 100MB?}
B -->|否| C[提示文件过大错误]
B -->|是| D[计算图片哈希]
D --> E{哈希已存在OCR结果?}
E -->|是| F[直接使用缓存的OCR结果]
E -->|否| G[调用OCR API]
G --> H[存储OCR结果到哈希缓存]
F --> I[打断现有ghost text]
H --> I
I --> J[插入图片到编辑器]
```
## 关键代码修改位置
| 文件 | 函数/位置 | 修改内容 |
|------|-----------|----------|
| `src/utils/ocrCache.js` | 新增 | 添加哈希相关函数和常量 |
| `src/components/MilkdownEditor.vue` | `handleImageUpload` | 添加大小检查、哈希检查、打断ghost text |
| `src/components/MilkdownEditor.vue` | `performOCR` | 接收哈希参数 |
| `src/components/MilkdownEditor.vue` | `onUpload` | Milkdown上传回调添加同样逻辑 |
+50 -68
View File
@@ -108,8 +108,8 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state' import { Selection } from '@milkdown/prose/state'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin' import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { fetchSuggestion } from '../utils/api.js' import { fetchSuggestion } from '../utils/api.js'
import { DEBUG, OCR_URL } from '../utils/config.js' import { OCR_URL } from '../utils/config.js'
import { setOcrCache, clearOcrCache, clearAllOcrCache } from '../utils/ocrCache.js' import { setOcrCache, clearOcrCache, clearAllOcrCache, IMAGE_SIZE_LIMIT, calculateImageHash, getOcrByHash, setOcrByHash } from '../utils/ocrCache.js'
const emit = defineEmits(['update:markdown']) const emit = defineEmits(['update:markdown'])
@@ -130,7 +130,6 @@ const aiButtonLabel = computed(() => {
let crepe = null let crepe = null
let markdownSyncTimer = null let markdownSyncTimer = null
let debugLogTimer = null
const objectUrls = new Set() const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
@@ -201,67 +200,24 @@ const scheduleMarkdownSync = () => {
const markdown = await crepe.getMarkdown() const markdown = await crepe.getMarkdown()
emit('update:markdown', markdown) emit('update:markdown', markdown)
} catch (e) { } catch (e) {
if (DEBUG) console.error('[Markdown] Sync failed:', e) // sync error, ignore
} }
}, 120) }, 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) => { const clearCurrentSuggestion = (view) => {
clearGhostSuggestion(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 if (!aiEnabled.value) return
const reader = new FileReader() const reader = new FileReader()
@@ -289,6 +245,9 @@ const performOCR = async (file, cacheKey) => {
if (data.text) { if (data.text) {
setOcrCache(cacheKey, data.text) setOcrCache(cacheKey, data.text)
setOcrCache(file.name, data.text) setOcrCache(file.name, data.text)
if (imageHash) {
setOcrByHash(imageHash, data.text)
}
} }
} catch (e) { } catch (e) {
console.error('[OCR] Error:', e) console.error('[OCR] Error:', e)
@@ -298,10 +257,8 @@ const performOCR = async (file, cacheKey) => {
} }
onMounted(async () => { onMounted(async () => {
if (DEBUG) console.log('[Debug] onMounted called')
if (!root.value) throw new Error('root.value is null') if (!root.value) throw new Error('root.value is null')
if (DEBUG) console.log('[Debug] Creating Crepe editor...')
crepe = new Crepe({ crepe = new Crepe({
root: root.value, root: root.value,
defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...', defaultValue: '# Welcome to LLM in text\n\nStart writing your content here...',
@@ -318,10 +275,24 @@ onMounted(async () => {
inlineEditConfirm: 'Escape' inlineEditConfirm: 'Escape'
}, },
[Crepe.Feature.ImageBlock]: { [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) const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl) 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 return objectUrl
} }
} }
@@ -358,9 +329,6 @@ onMounted(async () => {
refreshSizeAndLimit(ctx) refreshSizeAndLimit(ctx)
}) })
scheduleMarkdownSync() scheduleMarkdownSync()
debugLogTimer = setInterval(logDebugInfo, 20000)
if (DEBUG) console.log('[Debug] Crepe editor created with copilot plugin')
}) })
const exportMarkdown = async () => { const exportMarkdown = async () => {
@@ -450,9 +418,27 @@ const handleImageUpload = async (event) => {
const file = event.target.files?.[0] const file = event.target.files?.[0]
if (!file) return 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) const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl) 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) insertImageAtCursor(objectUrl)
event.target.value = '' event.target.value = ''
@@ -472,10 +458,6 @@ onUnmounted(() => {
clearTimeout(markdownSyncTimer) clearTimeout(markdownSyncTimer)
markdownSyncTimer = null markdownSyncTimer = null
} }
if (debugLogTimer) {
clearInterval(debugLogTimer)
debugLogTimer = null
}
for (const url of Array.from(objectUrls)) { for (const url of Array.from(objectUrls)) {
revokeObjectUrl(url) revokeObjectUrl(url)
+9 -12
View File
@@ -9,7 +9,6 @@ import { getOcrCache, OCR_SIZE_LIMIT, extractTextFromOCR } from '../utils/ocrCac
const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot') const COPILOT_PLUGIN_KEY = new PluginKey('milkdown-copilot')
const DEBOUNCE_MS = 1000 const DEBOUNCE_MS = 1000
const SIZE_LIMIT = OCR_SIZE_LIMIT const SIZE_LIMIT = OCR_SIZE_LIMIT
const DEBUG = true
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock']) const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
interface CopilotState { interface CopilotState {
@@ -334,12 +333,7 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
const doc = view.state.doc const doc = view.state.doc
const schema = view.state.schema const schema = view.state.schema
const overLimit = doc.content.size > SIZE_LIMIT const baseSize = doc.content.size
if (overLimit) {
setCopilotEnabled(view, false)
return
}
const serializer = runtime.ctx.get(serializerCtx) const serializer = runtime.ctx.get(serializerCtx)
let prefixMarkdown = '' let prefixMarkdown = ''
@@ -362,12 +356,15 @@ function scheduleFetch(view: EditorView, runtime: CopilotRuntime, pos: number) {
} }
const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}` const requestPrefix = `${prefixMarkdown}${buildOcrContextForRequest(doc, pos)}`
const totalTextLen = (prefixMarkdown + suffixMarkdown).length
const ocrContextLen = requestPrefix.length - prefixMarkdown.length
const totalWithOcr = totalTextLen + ocrContextLen
if (DEBUG) { const overLimit = totalWithOcr > SIZE_LIMIT
console.log('[Copilot] ===== LLM Request =====')
console.log('[Copilot] PREFIX:', requestPrefix) if (overLimit) {
console.log('[Copilot] SUFFIX:', suffixMarkdown) setCopilotEnabled(view, false)
console.log('[Copilot] ======================') return
} }
if (runtime.debounceTimer) { 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) { 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 { try {
const res = await fetch(apiUrl, { const res = await fetch(apiUrl, {
method: 'POST', method: 'POST',
@@ -10,7 +9,6 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
signal signal
}) })
if (DEBUG) console.log('[Debug] fetchSuggestion response status:', res.status)
if (!res.ok) { if (!res.ok) {
const errorText = await res.text() const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`) 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() const reader = res.body?.getReader()
if (!reader) { if (!reader) {
if (DEBUG) console.log('[Debug] No reader available')
throw new Error('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) const data = JSON.parse(jsonStr)
if (data.content) { if (data.content) {
text += data.content text += data.content
if (DEBUG) console.log('[Debug] Added content:', data.content)
} }
if (data.done || data.error) break if (data.done || data.error) break
} catch (e) { } 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 return text
} catch (e) { } catch (e) {
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
if (DEBUG) console.log('[Debug] Request aborted') // ignore abort
} else { } 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 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) { export function setOcrCache(filename, text) {
ocrCache.set(filename, text) ocrCache.set(filename, text)