diff --git a/backend/__pycache__/prompt.cpython-310.pyc b/backend/__pycache__/prompt.cpython-310.pyc index bae675f..02c0616 100644 Binary files a/backend/__pycache__/prompt.cpython-310.pyc and b/backend/__pycache__/prompt.cpython-310.pyc differ diff --git a/backend/prompt.py b/backend/prompt.py index 14dbfd0..14537ef 100644 --- a/backend/prompt.py +++ b/backend/prompt.py @@ -46,7 +46,7 @@ Hard rules: Do NOT repeat text that already appears at the start of SUFFIX. 3. Balanced length: 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). 4. Avoid trivial output: Do not output only punctuation or filler such as ".", ",", ";", ":". @@ -89,6 +89,3 @@ Now produce the insertion. Output:""" return prompt.strip() - - - diff --git a/plans/image-processing-plan.md b/plans/image-processing-plan.md new file mode 100644 index 0000000..50cda7e --- /dev/null +++ b/plans/image-processing-plan.md @@ -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上传回调添加同样逻辑 | diff --git a/src/components/MilkdownEditor.vue b/src/components/MilkdownEditor.vue index 655c728..23e121e 100644 --- a/src/components/MilkdownEditor.vue +++ b/src/components/MilkdownEditor.vue @@ -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) diff --git a/src/plugins/copilotPlugin.ts b/src/plugins/copilotPlugin.ts index 8b14d75..c374341 100644 --- a/src/plugins/copilotPlugin.ts +++ b/src/plugins/copilotPlugin.ts @@ -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) { diff --git a/src/utils/api.js b/src/utils/api.js index 903bdf9..6e153eb 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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 } } diff --git a/src/utils/ocrCache.js b/src/utils/ocrCache.js index b0d0ceb..b9e69e0 100644 --- a/src/utils/ocrCache.js +++ b/src/utils/ocrCache.js @@ -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)