feat(plugin): add document export, doc‑block, and TTS/ASR support
Adds a DocBlock component that renders embedded documents, new export buttons for DOCX and PDF, and updates the file‑upload picker to accept *.txt, *.docx, *.pptx, and *.pdf. Introduces a DOCX→PDF conversion bridge in the backend and new /tts and /asr endpoints that expose TTS and speech‑recognition functionality. The README is rewritten to describe the new features and clean up legacy documentation. All changes are backward‑compatible and do not introduce breaking API changes.
This commit is contained in:
@@ -5,3 +5,4 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.imageteac
|
||||
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
|
||||
export const OCR_URL = import.meta.env.VITE_OCR_URL || `${API_BASE_URL}/v1/ocr`
|
||||
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 || '/v1/export/pdf'
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function convertFileToMarkdown(file) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'your-secret-key-here',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file: base64,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
export const DOC_BLOCK_NODE_TYPE = 'doc_block'
|
||||
export const DOC_BLOCK_FENCE_LANG = 'llm-file'
|
||||
export const DOC_CONTEXT_LIMIT = 32 * 1024
|
||||
|
||||
const IMAGE_MD_RE = /!\[[^\]]*]\([^)]+\)/g
|
||||
const IMAGE_HTML_RE = /<img\b[^>]*>/gi
|
||||
const HEADER_SEPARATOR = '\n---\n'
|
||||
|
||||
export function normalizeDocType(value = '') {
|
||||
const lower = String(value || '').trim().toLowerCase()
|
||||
if (lower === 'txt' || lower === 'text' || lower === 'plain') return 'txt'
|
||||
if (lower === 'doc' || lower === 'docx' || lower === 'word') return 'docx'
|
||||
if (lower === 'ppt' || lower === 'pptx' || lower === 'powerpoint') return 'pptx'
|
||||
if (lower === 'pdf') return 'pdf'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export function getDocTypeFromFilename(name = '') {
|
||||
const lower = String(name || '').toLowerCase()
|
||||
if (lower.endsWith('.docx')) return 'docx'
|
||||
if (lower.endsWith('.pptx')) return 'pptx'
|
||||
if (lower.endsWith('.pdf')) return 'pdf'
|
||||
return 'txt'
|
||||
}
|
||||
|
||||
export function isSupportedDocFile(file) {
|
||||
if (!file) return false
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
const type = String(file.type || '').toLowerCase()
|
||||
return (
|
||||
name.endsWith('.txt') ||
|
||||
name.endsWith('.docx') ||
|
||||
name.endsWith('.pptx') ||
|
||||
name.endsWith('.pdf') ||
|
||||
type === 'text/plain' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
type === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
type === 'application/pdf'
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeDocContent(markdown = '') {
|
||||
return String(markdown || '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(IMAGE_MD_RE, '')
|
||||
.replace(IMAGE_HTML_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function quoteMeta(value = '') {
|
||||
return JSON.stringify(String(value ?? ''))
|
||||
}
|
||||
|
||||
function parseMetaLine(line = '') {
|
||||
const idx = line.indexOf(':')
|
||||
if (idx < 0) return null
|
||||
const key = line.slice(0, idx).trim()
|
||||
const rawValue = line.slice(idx + 1).trim()
|
||||
if (!key) return null
|
||||
try {
|
||||
return [key, JSON.parse(rawValue)]
|
||||
} catch {
|
||||
return [key, rawValue]
|
||||
}
|
||||
}
|
||||
|
||||
function pickFence(content = '') {
|
||||
const matches = String(content || '').match(/`{3,}/g) || []
|
||||
const maxLen = matches.reduce((max, item) => Math.max(max, item.length), 2)
|
||||
return '`'.repeat(maxLen + 1)
|
||||
}
|
||||
|
||||
export function buildDocBlockValue(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const docName = String(attrs.docName || `document.${docType}`)
|
||||
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
|
||||
const collapsed = Boolean(attrs.collapsed)
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
return [
|
||||
`type: ${quoteMeta(docType)}`,
|
||||
`name: ${quoteMeta(docName)}`,
|
||||
`uploadTime: ${quoteMeta(uploadTime)}`,
|
||||
`collapsed: ${collapsed ? 'true' : 'false'}`,
|
||||
'---',
|
||||
content,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function parseDocBlockValue(raw = '') {
|
||||
const normalized = String(raw || '').replace(/\r\n?/g, '\n')
|
||||
const separatorIndex = normalized.indexOf(HEADER_SEPARATOR)
|
||||
const headerText = separatorIndex >= 0 ? normalized.slice(0, separatorIndex) : ''
|
||||
const bodyText = separatorIndex >= 0 ? normalized.slice(separatorIndex + HEADER_SEPARATOR.length) : normalized
|
||||
const attrs = {
|
||||
docType: 'txt',
|
||||
docName: 'document.txt',
|
||||
uploadTime: '',
|
||||
collapsed: false,
|
||||
content: sanitizeDocContent(bodyText),
|
||||
}
|
||||
|
||||
for (const line of headerText.split('\n')) {
|
||||
const parsed = parseMetaLine(line)
|
||||
if (!parsed) continue
|
||||
const [key, value] = parsed
|
||||
if (key === 'type') attrs.docType = normalizeDocType(value)
|
||||
if (key === 'name' && value) attrs.docName = String(value)
|
||||
if (key === 'uploadTime' && value) attrs.uploadTime = String(value)
|
||||
if (key === 'collapsed') attrs.collapsed = value === true || value === 'true'
|
||||
}
|
||||
|
||||
if (!attrs.docName) attrs.docName = `document.${attrs.docType}`
|
||||
return attrs
|
||||
}
|
||||
|
||||
export function buildDocBlockMarkdown(attrs = {}) {
|
||||
const value = buildDocBlockValue(attrs)
|
||||
const fence = pickFence(value)
|
||||
return `${fence}${DOC_BLOCK_FENCE_LANG}\n${value}\n${fence}`
|
||||
}
|
||||
|
||||
export function buildDocContextFence(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
const fence = pickFence(content)
|
||||
return `${fence}${docType}\n${content}\n${fence}`
|
||||
}
|
||||
|
||||
export function buildLegacyDocBlock(attrs = {}) {
|
||||
const docType = normalizeDocType(attrs.docType)
|
||||
const docName = String(attrs.docName || `document.${docType}`)
|
||||
const uploadTime = String(attrs.uploadTime || new Date().toISOString())
|
||||
const content = sanitizeDocContent(attrs.content || '')
|
||||
return `<doc_type="${docType}" doc_name="${docName}" upload_time="${uploadTime}" collapsed="${Boolean(attrs.collapsed)}">\n${content}\n</doc_end>`
|
||||
}
|
||||
|
||||
export function parseLegacyDocBlock(raw = '') {
|
||||
const match = String(raw || '').match(/^<doc_type="([^"]+)"\s+doc_name="([^"]+)"\s+upload_time="([^"]+)"(?:\s+collapsed="([^"]+)")?>\n?([\s\S]*?)\n?<\/doc_end>$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
docType: normalizeDocType(match[1]),
|
||||
docName: match[2] || 'document.txt',
|
||||
uploadTime: match[3] || '',
|
||||
collapsed: match[4] === 'true',
|
||||
content: sanitizeDocContent(match[5] || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function transformDocBlockMarkdownForClipboard(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
||||
const replacedFence = String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
|
||||
const attrs = parseDocBlockValue(value)
|
||||
return `${prefix}${buildDocContextFence(attrs)}`
|
||||
})
|
||||
return replacedFence.replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
|
||||
const attrs = parseLegacyDocBlock(full)
|
||||
return attrs ? buildDocContextFence(attrs) : full
|
||||
})
|
||||
}
|
||||
|
||||
export function stripDocBlockMarkdown(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n[\s\S]*?\n\2(?=\n|$)/g
|
||||
return String(markdown || '').replace(pattern, '$1').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
export function transformLegacyDocBlocksForExport(markdown = '') {
|
||||
return String(markdown || '').replace(/<doc_type="[^"]+"\s+doc_name="[^"]+"\s+upload_time="[^"]+"(?:\s+collapsed="[^"]+")?>[\s\S]*?<\/doc_end>/g, (full) => {
|
||||
const attrs = parseLegacyDocBlock(full)
|
||||
return attrs ? buildDocBlockMarkdown(attrs) : full
|
||||
})
|
||||
}
|
||||
|
||||
export function transformSpecialDocBlocksToLegacy(markdown = '') {
|
||||
const pattern = /(^|\n)(`{3,})llm-file[^\n]*\n([\s\S]*?)\n\2(?=\n|$)/g
|
||||
return String(markdown || '').replace(pattern, (full, prefix, _fence, value) => {
|
||||
const attrs = parseDocBlockValue(value)
|
||||
return `${prefix}${buildLegacyDocBlock(attrs)}`
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user