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:
+34
-72
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
@@ -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') }
|
||||
}
|
||||
@@ -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(/\/+$/, '')
|
||||
}
|
||||
Reference in New Issue
Block a user