Refactor and enhance OCR and API functionalities

- Removed obsolete unit tests for TTS/ASR module.
- Deleted unused sample video file.
- Introduced OCRImageWrapper component for better OCR image handling with loading, success, and failure states.
- Updated copilot plugin to improve transaction handling and added new types for better type safety.
- Enhanced web search block plugin to support streaming content updates.
- Refactored API utility functions for better error handling and consistency across requests.
- Added new configuration for OCR API endpoint.
- Consolidated SSE event parsing into a shared utility.
- Created string utility functions to reduce code duplication.
- Removed outdated test documents related to compression functionality.
This commit is contained in:
“ydy0615”
2026-06-10 14:47:51 +08:00
parent 17d211bf93
commit 2283020e51
23 changed files with 641 additions and 1446 deletions
+34 -72
View File
@@ -12,6 +12,9 @@ import {
COMPRESS_STATUS_URL,
JOB_LOAD_URL,
} from './config.js'
import { safeString, stripTrailingSlashes } from './string.js'
import { parseSseEvent } from './sse.js'
import { safeFetch, buildHeaders, parseJsonResponse } from './fetch.js'
import { useSettingsStore } from '../stores/settings'
function generateRequestId() {
@@ -22,32 +25,7 @@ function generateRequestId() {
}
function normalizeAbortReason(reason) {
if (typeof reason === 'string' && reason.trim()) {
return reason.trim().slice(0, 64)
}
return 'abort'
}
function parseSseEvent(rawEvent) {
const lines = String(rawEvent || '').replace(/\r/g, '').split('\n')
let event = 'message'
const dataLines = []
for (const line of lines) {
if (!line) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
return {
event,
data: dataLines.join('\n'),
}
return safeString(reason).trim().slice(0, 64) || 'abort'
}
function createAbortError(message = 'Request aborted') {
@@ -78,18 +56,15 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
}
}
const CANCEL_PATH_MAP = {
'/v1/pro/completions': '/v1/pro/completions/cancel',
'/v1/web-search': '/v1/web-search/cancel',
'/v1/completions': '/v1/completions/cancel',
}
function getCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (/\/v1\/pro\/completions$/i.test(normalized)) {
return normalized.replace(/\/v1\/pro\/completions$/i, '/v1/pro/completions/cancel')
}
if (/\/v1\/web-search$/i.test(normalized)) {
return normalized.replace(/\/v1\/web-search$/i, '/v1/web-search/cancel')
}
if (/\/v1\/completions$/i.test(normalized)) {
return normalized.replace(/\/v1\/completions$/i, '/v1/completions/cancel')
}
return `${normalized}/cancel`
const base = stripTrailingSlashes(apiUrl)
return CANCEL_PATH_MAP[base] || `${base}/cancel`
}
function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) {
@@ -311,6 +286,7 @@ export async function fetchWebSearchStream(payload, apiUrl = WEB_SEARCH_URL) {
signal,
timeoutMs = WEB_SEARCH_FRONTEND_TIMEOUT_MS,
onEvent,
onDelta,
} = payload || {}
const settings = useSettingsStore()
@@ -341,6 +317,10 @@ export async function fetchWebSearchStream(payload, apiUrl = WEB_SEARCH_URL) {
onEvent?.(String(data?.phase || ''), data)
return
}
if (event === 'delta') {
onDelta?.(data)
return
}
if (event === 'error') {
onEvent?.('error', data)
}
@@ -367,43 +347,28 @@ export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) {
}
export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) {
const res = await fetch(apiUrl, {
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
const res = await safeFetch(apiUrl, {
headers: buildHeaders({ 'Content-Type': 'application/json' }),
})
if (!res.ok) throw new Error(`TTS Status HTTP ${res.status}`)
return res.json()
return parseJsonResponse(res)
}
export async function fetchTTSConfig(apiUrl = TTS_CONFIG_URL) {
const res = await fetch(apiUrl, {
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
const res = await safeFetch(apiUrl, {
headers: buildHeaders({ 'Content-Type': 'application/json' }),
})
if (!res.ok) throw new Error(`TTS Config HTTP ${res.status}`)
return res.json()
return parseJsonResponse(res)
}
export async function submitCompress(content, docType = 'txt', apiUrl = COMPRESS_SUBMIT_URL) {
const headers = {
'Content-Type': 'application/json',
}
if (API_KEY) {
headers['X-API-Key'] = API_KEY
}
const res = await fetch(apiUrl, {
const res = await safeFetch(apiUrl, {
method: 'POST',
headers,
credentials: 'include',
headers: buildHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ content, docType }),
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`压缩提交失败 HTTP ${res.status}: ${errorText}`)
}
return res.json()
return parseJsonResponse(res)
}
export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STATUS_URL) {
@@ -411,11 +376,9 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT
const interval = setInterval(async () => {
try {
const res = await fetch(`${apiUrl}?task_id=${encodeURIComponent(taskId)}`, {
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
const res = await safeFetch(`${apiUrl}?task_id=${encodeURIComponent(taskId)}`, {
headers: buildHeaders({ 'Content-Type': 'application/json' }),
})
if (!res.ok) {
consecutiveErrors++
if (consecutiveErrors >= 5) {
@@ -426,7 +389,7 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT
}
consecutiveErrors = 0
const data = await res.json()
const data = await parseJsonResponse(res)
onStateChange(data.status, data.content || '', data.message, data)
if (['completed', 'error', 'cancelled'].includes(data.status)) {
@@ -445,12 +408,11 @@ export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STAT
}
export async function fetchJobLoad(apiUrl = JOB_LOAD_URL) {
const res = await fetch(apiUrl, {
headers: API_KEY ? { 'X-API-Key': API_KEY } : {},
credentials: 'include',
const res = await safeFetch(apiUrl, {
headers: buildHeaders({ 'Content-Type': 'application/json' }),
})
if (!res.ok) {
throw new Error(`Job Load HTTP ${res.status}`)
}
return res.json()
return parseJsonResponse(res)
}
+1
View File
@@ -9,6 +9,7 @@ export const PRO_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_PRO_FRONTEND_
export const WEB_SEARCH_URL = import.meta.env.VITE_WEB_SEARCH_URL || `${API_BASE_URL}/v1/web-search`
export const WEB_SEARCH_FRONTEND_TIMEOUT_MS = Number(import.meta.env.VITE_WEB_SEARCH_FRONTEND_TIMEOUT_MS || 3660000)
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
export const OCR_CONFIG_URL = import.meta.env.VITE_OCR_CONFIG_URL || `${API_BASE_URL}/v1/ocr/config`
export const CONVERT_URL = import.meta.env.VITE_CONVERT_URL || `${API_BASE_URL}/v1/convert`
export const EXPORT_PDF_URL = import.meta.env.VITE_EXPORT_PDF_URL || `${API_BASE_URL}/v1/export/pdf`
export const TTS_URL = import.meta.env.VITE_TTS_URL || `${API_BASE_URL}/v1/tts-asr/tts`
+1 -21
View File
@@ -1,26 +1,6 @@
import { CONVERT_URL, ASR_URL } from './config.js'
function parseSseEvent(rawEvent) {
const lines = String(rawEvent || '').replace(/\r/g, '').split('\n')
let event = 'message'
const dataLines = []
for (const line of lines) {
if (!line) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
return {
event,
data: dataLines.join('\n'),
}
}
import { parseSseEvent } from './sse.js'
async function consumeSseResult(res) {
if (!res.ok) {
+54
View File
@@ -0,0 +1,54 @@
/**
* Unified safeFetch wrapper for API requests.
* Handles authentication headers, credentials, and error mapping consistently across all endpoints.
*/
import { API_KEY } from './config.js'
/** Build headers for JSON API requests with optional X-API-Key */
function buildHeaders(extra = {}) {
return API_KEY
? { 'X-API-Key': API_KEY, ...extra }
: { ...extra }
}
/** Parse JSON response from fetch result, handling non-OK status codes */
async function parseJsonResponse(res) {
if (!res.ok) {
let message = `HTTP ${res.status}`
try {
const data = await res.json()
message = data.detail || data.error || message
} catch {
const text = await res.text()
if (text) message = text
}
throw new Error(message)
}
return res.json()
}
/**
* Fetch wrapper that handles:
* - Authentication headers (X-API-Key)
* - Credentials (include cookies)
* - Error mapping (HTTP status → meaningful message)
*/
export async function safeFetch(url, options = {}) {
const headers = buildHeaders(options.headers || {})
const res = await fetch(url, {
...options,
headers,
credentials: 'include',
})
if (!res.ok) {
const errorText = await res.text()
throw new Error(`HTTP ${res.status}: ${errorText}`)
}
return res
}
/** Export helper functions for use in other modules */
export { buildHeaders, parseJsonResponse }
+43 -7
View File
@@ -1,16 +1,55 @@
const SIZE_LIMIT = 32 * 1024
export const IMAGE_SIZE_LIMIT = 100 * 1024 * 1024
// ─── Status enum ───────────────────────────────────────────────
export const OcrStatus = Object.freeze({
PENDING: 'pending',
LOADING: 'loading',
SUCCESS: 'success',
FAILED: 'failed',
})
// ─── State cache (per-image-hash) ──────────────────────────────
// Map<hash, { status, text?, error?, updatedAt }>
const ocrStateCache = new Map()
// ─── Legacy text cache (kept for backward compat) ──────────────
const ocrCache = new Map()
const imageHashCache = new Map()
// ─── Hash utilities ────────────────────────────────────────────
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('')
}
// ─── State API (new) ───────────────────────────────────────────
export function setOcrState(hash, status, text = '', error = '') {
ocrStateCache.set(hash, {
status,
text: typeof text === 'string' ? text : '',
error: typeof error === 'string' ? error : '',
updatedAt: Date.now(),
})
}
export function getOcrState(hash) {
return ocrStateCache.get(hash) || { status: OcrStatus.PENDING, text: '', error: '' }
}
export function resetOcrState(hash) {
ocrStateCache.delete(hash)
}
export function clearAllOcrState() {
ocrStateCache.clear()
}
// ─── Legacy API (backward compat) ──────────────────────────────
export function getOcrByHash(hash) {
const state = ocrStateCache.get(hash)
if (state && state.status === OcrStatus.SUCCESS) return state.text
return imageHashCache.get(hash) || ''
}
@@ -38,6 +77,7 @@ export function clearAllOcrCache() {
ocrCache.clear()
}
// ─── Size utilities ────────────────────────────────────────────
export function calculateOcrSize(imageFilenames) {
let total = 0
for (const name of imageFilenames) {
@@ -54,12 +94,13 @@ export function checkSizeLimit(docTextSize, imageFilenames) {
size: total,
docSize: docTextSize,
ocrSize: ocrSize,
overLimit: total > SIZE_LIMIT
overLimit: total > SIZE_LIMIT,
}
}
export const OCR_SIZE_LIMIT = SIZE_LIMIT
// ─── Text extraction ───────────────────────────────────────────
export function extractTextFromOCR(ocrText, maxLen = 100) {
if (!ocrText) return ''
const match = ocrText.match(/TEXT:\s*([\s\S]*?)(?:KEY_DETAILS|LANGUAGE|SUMMARY|$)/i)
@@ -68,14 +109,9 @@ export function extractTextFromOCR(ocrText, maxLen = 100) {
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text
}
// ─── Context builder (for AI completion) ───────────────────────
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
/**
* 从 ProseMirror doc 中提取 OCR 上下文,供 AI 补全使用。
* @param {ProseNode} doc - ProseMirror document node
* @param {number} maxLen - OCR 文本最大长度
* @returns {string}
*/
export function buildOcrContextForDoc(doc, maxLen = 120) {
const lines = []
+2 -4
View File
@@ -1,8 +1,6 @@
const OUTER_FENCE_RE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1[ \t]*$/
import { normalizeNewlines } from './string.js'
function normalizeNewlines(value = '') {
return String(value || '').replace(/\r\n?/g, '\n')
}
const OUTER_FENCE_RE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1[ \t]*$/
function unescapeLiteralNewlines(value = '') {
const text = String(value || '')
+37
View File
@@ -0,0 +1,37 @@
/**
* Shared SSE (Server-Sent Events) parsing utilities.
* Previously duplicated in api.js and convert.js.
*/
/** Single parsed SSE event */
export interface SseEvent {
event: string
data: string
}
/**
* Parse a raw SSE event chunk into { event, data }.
* Handles multi-line data fields and explicit event type overrides.
*
* @example
* parseSseEvent('event: result\ndata: {"ok":true}')
* // → { event: 'result', data: '{"ok":true}' }
*/
export function parseSseEvent(rawEvent: string): SseEvent {
const lines = String(rawEvent || '').replace(/\r/g, '').split('\n')
let event = 'message'
const dataLines: string[] = []
for (const line of lines) {
if (!line) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
return { event, data: dataLines.join('\n') }
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Shared string utility functions.
* Extracted from repeated patterns across api.js, docBlock.js, proAccept.js, proBlock.js, webSearch.js.
*/
/**
* Normalize line endings to Unix-style (\n).
* Handles \r\n (Windows), \r (old Mac), and \n (Unix).
*/
export function normalizeNewlines(value: string = ''): string {
return String(value || '').replace(/\r\n?/g, '\n')
}
/**
* Safely coerce any value to a non-null string.
* Replaces the common `String(x || '')` pattern scattered across 20+ locations.
*/
export function safeString(value: unknown): string {
return String(value ?? '')
}
/**
* Remove trailing slashes from a URL/path string.
*/
export function stripTrailingSlashes(value: string = ''): string {
return safeString(value).replace(/\/+$/, '')
}