Stabilize pro editing without heavy office runtime
The workspace now carries the pro editing flow, streaming completion path, and lighter Office preview state as one checkpoint so the remote has the current runnable project shape. Constraint: Preserve the current workspace as a single reviewable project commit while excluding local agent state and verification artifacts. Removed stale Univer runtime dependencies from the lockfile so installs match package.json. Rejected: Commit runtime screenshots, .omx state, and coverage files | they are local artifacts rather than source state. Confidence: medium Scope-risk: broad Directive: Keep package.json and package-lock.json synchronized when changing frontend dependencies. Tested: npm run build; C:\Users\ydy\.conda\envs\llmwebsite\python.exe -m pytest backend/tests/test_main_endpoints.py backend/tests/test_main_cancel.py backend/tests/test_llm.py backend/tests/test_llm_extended.py -v -o addopts= (44 passed). Not-tested: Full pytest with repository coverage addopts currently reports 0% coverage because pytest-cov watches backend.* module names while tests import top-level backend modules. Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
+177
-13
@@ -1,4 +1,4 @@
|
||||
import { API_URL, API_KEY, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
|
||||
import { API_URL, API_KEY, PRO_STREAM_URL, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL } from './config.js'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
function generateRequestId() {
|
||||
@@ -11,6 +11,9 @@ function generateRequestId() {
|
||||
function getCancelUrl(apiUrl) {
|
||||
const normalized = String(apiUrl || '').replace(/\/+$/, '')
|
||||
if (!normalized) return '/v1/completions/cancel'
|
||||
if (/\/v1\/pro\/completions\/stream$/i.test(normalized)) {
|
||||
return normalized.replace(/\/v1\/pro\/completions\/stream$/i, '/v1/completions/cancel')
|
||||
}
|
||||
if (normalized.endsWith('/v1/completions')) {
|
||||
return `${normalized}/cancel`
|
||||
}
|
||||
@@ -42,6 +45,50 @@ async function sendCancelRequest(cancelUrl, requestId, reason) {
|
||||
}
|
||||
}
|
||||
|
||||
function createAbortError(message = 'Request aborted') {
|
||||
const error = new Error(message)
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) {
|
||||
return {
|
||||
prefix,
|
||||
suffix,
|
||||
languageId,
|
||||
model_thinking: settings.modelThinking,
|
||||
privacy_mode: settings.privacyMode,
|
||||
user_preferences: {
|
||||
language: settings.language,
|
||||
currency: settings.currency,
|
||||
timezone: settings.detectedTimezone,
|
||||
},
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
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'),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) {
|
||||
let normalizedLanguageId = 'markdown'
|
||||
if (typeof languageId === 'string' && languageId.trim()) {
|
||||
@@ -77,18 +124,7 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
'X-API-Key': API_KEY,
|
||||
}
|
||||
|
||||
const body = {
|
||||
prefix,
|
||||
suffix,
|
||||
languageId: normalizedLanguageId,
|
||||
model_thinking: settings.modelThinking,
|
||||
privacy_mode: settings.privacyMode,
|
||||
user_preferences: {
|
||||
language: settings.language,
|
||||
currency: settings.currency,
|
||||
timezone: settings.detectedTimezone,
|
||||
},
|
||||
}
|
||||
const body = buildCompletionBody(settings, prefix, suffix, normalizedLanguageId)
|
||||
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
@@ -117,6 +153,134 @@ export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProSuggestionStream(payload, apiUrl = PRO_STREAM_URL) {
|
||||
const {
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
languageId = 'markdown',
|
||||
signal,
|
||||
model = '',
|
||||
temperature = 0.7,
|
||||
timeoutMs = 600000,
|
||||
onChunk,
|
||||
} = payload || {}
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const requestId = generateRequestId()
|
||||
const cancelUrl = getCancelUrl(apiUrl)
|
||||
const requestController = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
requestController.abort('timeout')
|
||||
}, timeoutMs)
|
||||
|
||||
const relayAbort = () => {
|
||||
requestController.abort(signal?.reason || 'abort')
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
const reason = normalizeAbortReason(requestController.signal.reason)
|
||||
void sendCancelRequest(cancelUrl, requestId, reason)
|
||||
}
|
||||
|
||||
requestController.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
relayAbort()
|
||||
} else {
|
||||
signal.addEventListener('abort', relayAbort, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Id': requestId,
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildCompletionBody(settings, prefix, suffix, String(languageId || 'markdown').trim() || 'markdown', {
|
||||
model,
|
||||
temperature,
|
||||
})
|
||||
),
|
||||
signal: requestController.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('PRO 模式流式响应不可用')
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let finalContent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const chunk = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
|
||||
const parsed = parseSseEvent(chunk)
|
||||
if (parsed.event === 'chunk' && parsed.data) {
|
||||
const data = JSON.parse(parsed.data)
|
||||
const delta = String(data.delta || '')
|
||||
if (delta) {
|
||||
finalContent += delta
|
||||
onChunk?.(delta)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.event === 'done' && parsed.data) {
|
||||
const data = JSON.parse(parsed.data)
|
||||
return String(data.content || finalContent || '')
|
||||
}
|
||||
|
||||
if (parsed.event === 'error' && parsed.data) {
|
||||
const data = JSON.parse(parsed.data)
|
||||
throw new Error(String(data.error || 'PRO 模式请求失败'))
|
||||
}
|
||||
|
||||
if (parsed.event === 'cancelled') {
|
||||
throw createAbortError('PRO 模式请求已取消')
|
||||
}
|
||||
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
if (requestController.signal.aborted) {
|
||||
throw createAbortError('PRO 模式请求已中止')
|
||||
}
|
||||
|
||||
return finalContent
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') {
|
||||
throw e
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
requestController.signal.removeEventListener('abort', onAbort)
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', relayAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) {
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -4,6 +4,7 @@ const DEFAULT_API_BASE_URL = import.meta.env.DEV ? '' : 'https://api.imageteach.
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL || `${API_BASE_URL}/v1/completions`
|
||||
export const PRO_STREAM_URL = import.meta.env.VITE_PRO_STREAM_URL || `${API_BASE_URL}/v1/pro/completions/stream`
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function normalizeHiddenTextValue(value = '') {
|
||||
return String(value ?? '').replace(/\r\n?/g, ' ')
|
||||
}
|
||||
|
||||
export function escapeHiddenTextSegment(value = '', closingChar) {
|
||||
const normalized = normalizeHiddenTextValue(value)
|
||||
const closingCharPattern = new RegExp(`\\${closingChar}`, 'g')
|
||||
return normalized
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(closingCharPattern, `\\${closingChar}`)
|
||||
}
|
||||
|
||||
export function serializeHiddenTextSyntax(displayed = '', hidden = '') {
|
||||
const safeDisplayed = escapeHiddenTextSegment(displayed, ')')
|
||||
const safeHidden = escapeHiddenTextSegment(hidden, '}')
|
||||
return `(${safeDisplayed}){${safeHidden}}`
|
||||
}
|
||||
|
||||
function readHiddenTextSegment(text = '', start = 0, closingChar = ')') {
|
||||
let index = start
|
||||
let value = ''
|
||||
|
||||
while (index < text.length) {
|
||||
const char = text[index]
|
||||
|
||||
if (char === '\\') {
|
||||
const nextChar = text[index + 1]
|
||||
if (nextChar === undefined) return null
|
||||
value += nextChar
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\n' || char === '\r') return null
|
||||
if (char === closingChar) {
|
||||
return {
|
||||
value,
|
||||
end: index + 1,
|
||||
}
|
||||
}
|
||||
|
||||
value += char
|
||||
index += 1
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseHiddenTextAt(text = '', start = 0) {
|
||||
if (text[start] !== '(') return null
|
||||
|
||||
const displayed = readHiddenTextSegment(text, start + 1, ')')
|
||||
if (!displayed) return null
|
||||
if (text[displayed.end] !== '{') return null
|
||||
|
||||
const hidden = readHiddenTextSegment(text, displayed.end + 1, '}')
|
||||
if (!hidden) return null
|
||||
|
||||
return {
|
||||
start,
|
||||
end: hidden.end,
|
||||
displayed: displayed.value,
|
||||
hidden: hidden.value,
|
||||
raw: text.slice(start, hidden.end),
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHiddenTextMatches(text = '') {
|
||||
const matches = []
|
||||
let index = 0
|
||||
|
||||
while (index < text.length) {
|
||||
const match = parseHiddenTextAt(text, index)
|
||||
if (match) {
|
||||
matches.push(match)
|
||||
index = match.end
|
||||
continue
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
export function splitTextWithHiddenSyntax(text = '') {
|
||||
const matches = extractHiddenTextMatches(text)
|
||||
if (matches.length === 0) return null
|
||||
|
||||
const segments = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.start > cursor) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
value: text.slice(cursor, match.start),
|
||||
})
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: HIDDEN_TEXT_NODE_TYPE,
|
||||
displayed: match.displayed,
|
||||
hidden: match.hidden,
|
||||
})
|
||||
|
||||
cursor = match.end
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
value: text.slice(cursor),
|
||||
})
|
||||
}
|
||||
|
||||
return segments.filter((segment) => segment.type !== 'text' || segment.value)
|
||||
}
|
||||
|
||||
export function renderHiddenTextPreviewHtml(displayed = '', hidden = '') {
|
||||
const summary = escapeHtml(displayed || '未命名文本')
|
||||
|
||||
return [
|
||||
`<span class="hidden-text-preview" data-hidden-text="true" title="${escapeHtml(serializeHiddenTextSyntax(displayed, hidden))}">`,
|
||||
`<span class="hidden-text-preview__summary">${summary}</span>`,
|
||||
'</span>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
export function hiddenTextMarkdownItPlugin(md) {
|
||||
md.inline.ruler.before('emphasis', 'hidden_text', (state, silent) => {
|
||||
const match = parseHiddenTextAt(state.src, state.pos)
|
||||
if (!match) return false
|
||||
|
||||
if (!silent) {
|
||||
const token = state.push('hidden_text', '', 0)
|
||||
token.meta = {
|
||||
displayed: match.displayed,
|
||||
hidden: match.hidden,
|
||||
}
|
||||
}
|
||||
|
||||
state.pos = match.end
|
||||
return true
|
||||
})
|
||||
|
||||
md.renderer.rules.hidden_text = (tokens, index) => {
|
||||
const meta = tokens[index]?.meta || {}
|
||||
return renderHiddenTextPreviewHtml(meta.displayed, meta.hidden)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export const translations = {
|
||||
mediumDesc: 'Brief analysis before suggesting',
|
||||
highDesc: 'Deep, step-by-step analysis (Slowest)',
|
||||
debounceTime: 'Debounce Time',
|
||||
proMode: 'PRO Mode',
|
||||
proModeThinking: 'PRO Thinking',
|
||||
proModel: 'PRO Model',
|
||||
proModelPlaceholder: 'e.g. qwen3:32b',
|
||||
proModelDesc: 'Optional stronger model name used only by PRO mode.',
|
||||
proModelEmptyHint: 'Leave empty to use the backend default PRO model.',
|
||||
privacyPreferences: 'Privacy & Preferences',
|
||||
privacyMode: 'Privacy Mode',
|
||||
privacyDesc: 'Prevent sending IP and preferences to the AI',
|
||||
@@ -50,6 +56,27 @@ export const translations = {
|
||||
uploading: 'Uploading files...',
|
||||
enableAI: 'Enable AI',
|
||||
disableAI: 'Disable AI',
|
||||
template: 'Template',
|
||||
presetTemplates: 'Preset Templates',
|
||||
customTemplates: 'Custom Templates',
|
||||
newTemplate: 'New Template',
|
||||
previewTemplate: 'Preview Template',
|
||||
applyTemplate: 'Apply Template',
|
||||
copyAsTemplate: 'Copy as Custom Template',
|
||||
editTemplate: 'Edit Template',
|
||||
saveTemplate: 'Save Template',
|
||||
templateName: 'Template Name',
|
||||
templateContent: 'Template Content',
|
||||
templateNamePlaceholder: 'e.g. Meeting Notes',
|
||||
templateContentPlaceholder: 'Enter template content here...',
|
||||
noTemplates: 'No custom templates yet',
|
||||
templateNameRequired: 'Template name is required.',
|
||||
templateContentRequired: 'Template content is required.',
|
||||
templateNameDuplicate: 'Template name already exists.',
|
||||
templateDeleteConfirm: 'Delete this template?',
|
||||
templateSaved: 'Template saved.',
|
||||
templateUpdated: 'Template updated.',
|
||||
templateDeleted: 'Template deleted.',
|
||||
insertUrl: 'Insert Image from URL',
|
||||
insert: 'Insert',
|
||||
cancel: 'Cancel',
|
||||
@@ -114,6 +141,12 @@ export const translations = {
|
||||
mediumDesc: '简要分析上下文后建议',
|
||||
highDesc: '深度逐步分析(最慢但质量最高)',
|
||||
debounceTime: '防抖时间',
|
||||
proMode: 'PRO模式思考',
|
||||
proModeThinking: 'PRO 正在思考',
|
||||
proModel: 'PRO 模型',
|
||||
proModelPlaceholder: '例如 qwen3:32b',
|
||||
proModelDesc: '可选。仅在 PRO 模式下使用的更强模型名称。',
|
||||
proModelEmptyHint: '留空则使用后端默认 PRO 模型。',
|
||||
privacyPreferences: '隐私与偏好',
|
||||
privacyMode: '隐私模式',
|
||||
privacyDesc: '不向 AI 发送 IP 地址和偏好设置',
|
||||
@@ -142,6 +175,27 @@ export const translations = {
|
||||
uploading: '正在上传文件...',
|
||||
enableAI: '启用 AI',
|
||||
disableAI: '禁用 AI',
|
||||
template: '模板',
|
||||
presetTemplates: '预设模板',
|
||||
customTemplates: '自定义模板',
|
||||
newTemplate: '新建模板',
|
||||
previewTemplate: '预览模板',
|
||||
applyTemplate: '应用模板',
|
||||
copyAsTemplate: '复制为自定义模板',
|
||||
editTemplate: '编辑模板',
|
||||
saveTemplate: '保存模板',
|
||||
templateName: '模板名称',
|
||||
templateContent: '模板内容',
|
||||
templateNamePlaceholder: '例如:会议纪要',
|
||||
templateContentPlaceholder: '在这里输入模板内容...',
|
||||
noTemplates: '暂无自定义模板',
|
||||
templateNameRequired: '请输入模板名称',
|
||||
templateContentRequired: '请输入模板内容',
|
||||
templateNameDuplicate: '模板名称已存在',
|
||||
templateDeleteConfirm: '确认删除此模板吗?',
|
||||
templateSaved: '模板已保存',
|
||||
templateUpdated: '模板已更新',
|
||||
templateDeleted: '模板已删除',
|
||||
insertUrl: '通过 URL 插入图片',
|
||||
insert: '插入',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export const PRO_BLOCK_NODE_TYPE = 'pro_block'
|
||||
export const PRO_TRIGGER_TEXT = '[PRO]'
|
||||
export const PRO_DISPLAY_LABEL = 'PRO模式思考'
|
||||
|
||||
const PRO_PENDING_PREFIX = '[Pro][{'
|
||||
const PRO_PENDING_SUFFIX = '}]'
|
||||
const PRO_TRIGGER_RE = /^\[pro\]$/i
|
||||
|
||||
function normalizeMarkdownText(value = '') {
|
||||
return String(value || '').replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
export function escapeProBlockContent(value = '') {
|
||||
return normalizeMarkdownText(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/]/g, '\\]')
|
||||
.replace(/}/g, '\\}')
|
||||
}
|
||||
|
||||
export function unescapeProBlockContent(value = '') {
|
||||
const normalized = String(value || '')
|
||||
let result = ''
|
||||
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
const char = normalized[index]
|
||||
if (char !== '\\' || index === normalized.length - 1) {
|
||||
result += char
|
||||
continue
|
||||
}
|
||||
|
||||
const next = normalized[index + 1]
|
||||
if (next === 'n') {
|
||||
result += '\n'
|
||||
} else {
|
||||
result += next
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function serializeProBlockSyntax(content = '') {
|
||||
const normalized = normalizeMarkdownText(content)
|
||||
if (!normalized) return PRO_TRIGGER_TEXT
|
||||
return `${PRO_PENDING_PREFIX}${escapeProBlockContent(normalized)}${PRO_PENDING_SUFFIX}`
|
||||
}
|
||||
|
||||
export function parseProBlockSyntax(value = '') {
|
||||
const text = normalizeMarkdownText(value).trim()
|
||||
if (!text) return null
|
||||
|
||||
if (PRO_TRIGGER_RE.test(text)) {
|
||||
return {
|
||||
content: '',
|
||||
autoStart: true,
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = text.slice(0, PRO_PENDING_PREFIX.length)
|
||||
if (prefix.toLowerCase() === PRO_PENDING_PREFIX.toLowerCase() && text.endsWith(PRO_PENDING_SUFFIX)) {
|
||||
return {
|
||||
content: unescapeProBlockContent(
|
||||
text.slice(PRO_PENDING_PREFIX.length, text.length - PRO_PENDING_SUFFIX.length)
|
||||
),
|
||||
autoStart: false,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
export const UPLOAD_BLOCK_NODE_TYPE = 'upload_block'
|
||||
|
||||
export const DEFAULT_UPLOAD_BLOCK_TYPES = [
|
||||
'docx',
|
||||
'pptx',
|
||||
'pdf',
|
||||
'txt',
|
||||
'json',
|
||||
'toml',
|
||||
'yaml',
|
||||
'images',
|
||||
]
|
||||
|
||||
const TYPE_ALIAS = {
|
||||
doc: 'docx',
|
||||
docx: 'docx',
|
||||
word: 'docx',
|
||||
ppt: 'pptx',
|
||||
pptx: 'pptx',
|
||||
powerpoint: 'pptx',
|
||||
pdf: 'pdf',
|
||||
txt: 'txt',
|
||||
text: 'txt',
|
||||
plain: 'txt',
|
||||
json: 'json',
|
||||
toml: 'toml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
'[images]': 'images',
|
||||
image: 'images',
|
||||
images: 'images',
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
docx: 'DOCX',
|
||||
pptx: 'PPTX',
|
||||
pdf: 'PDF',
|
||||
txt: 'TXT',
|
||||
json: 'JSON',
|
||||
toml: 'TOML',
|
||||
yaml: 'YAML',
|
||||
images: '图片',
|
||||
}
|
||||
|
||||
const TYPE_ACCEPT_MAP = {
|
||||
docx: [
|
||||
'.docx',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
],
|
||||
pptx: [
|
||||
'.pptx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
],
|
||||
pdf: [
|
||||
'.pdf',
|
||||
'application/pdf',
|
||||
],
|
||||
txt: [
|
||||
'.txt',
|
||||
'text/plain',
|
||||
],
|
||||
json: [
|
||||
'.json',
|
||||
'application/json',
|
||||
],
|
||||
toml: [
|
||||
'.toml',
|
||||
'application/toml',
|
||||
'text/toml',
|
||||
],
|
||||
yaml: [
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'text/yaml',
|
||||
'text/x-yaml',
|
||||
'application/x-yaml',
|
||||
],
|
||||
images: ['image/*'],
|
||||
}
|
||||
|
||||
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
|
||||
const SYNTAX_RE = /^\{\{\{([\s\S]*?)\}\}\}$/
|
||||
const UPLOAD_TYPE_PREFIX_RE = /^upload\s+file\s+type\s*:\s*(.+)$/i
|
||||
|
||||
function parseStrictUploadBlockTypes(values = []) {
|
||||
const normalized = []
|
||||
|
||||
for (const value of values) {
|
||||
const next = normalizeUploadBlockType(value)
|
||||
if (!next) return null
|
||||
if (!normalized.includes(next)) {
|
||||
normalized.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
export function normalizeUploadBlockType(value = '') {
|
||||
const key = String(value || '').trim().toLowerCase()
|
||||
return TYPE_ALIAS[key] || ''
|
||||
}
|
||||
|
||||
export function normalizeUploadBlockTypes(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
const normalized = []
|
||||
|
||||
for (const item of source) {
|
||||
const next = normalizeUploadBlockType(item)
|
||||
if (!next || normalized.includes(next)) continue
|
||||
normalized.push(next)
|
||||
}
|
||||
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_UPLOAD_BLOCK_TYPES]
|
||||
}
|
||||
|
||||
export function getUploadBlockMenuOptions(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
||||
return normalizeUploadBlockTypes(allowedTypes).map((type) => ({
|
||||
value: type,
|
||||
label: TYPE_LABELS[type] || type.toUpperCase(),
|
||||
}))
|
||||
}
|
||||
|
||||
export function getUploadBlockAccept(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
||||
const entries = new Set()
|
||||
|
||||
normalizeUploadBlockTypes(allowedTypes).forEach((type) => {
|
||||
const values = TYPE_ACCEPT_MAP[type] || []
|
||||
values.forEach((entry) => entries.add(entry))
|
||||
})
|
||||
|
||||
return Array.from(entries).join(',')
|
||||
}
|
||||
|
||||
export function getUploadBlockAcceptForType(type = '') {
|
||||
const normalized = normalizeUploadBlockType(type)
|
||||
if (!normalized) return ''
|
||||
return (TYPE_ACCEPT_MAP[normalized] || []).join(',')
|
||||
}
|
||||
|
||||
function parseUploadBlockInner(raw = '') {
|
||||
const inner = String(raw || '').trim()
|
||||
if (!inner) {
|
||||
return { allowedTypes: [...DEFAULT_UPLOAD_BLOCK_TYPES] }
|
||||
}
|
||||
|
||||
const matched = inner.match(UPLOAD_TYPE_PREFIX_RE)
|
||||
if (!matched) return null
|
||||
|
||||
const segments = matched[1]
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (segments.length === 0) return null
|
||||
|
||||
const allowedTypes = parseStrictUploadBlockTypes(segments)
|
||||
if (!allowedTypes) return null
|
||||
|
||||
return allowedTypes.length > 0 ? { allowedTypes } : null
|
||||
}
|
||||
|
||||
export function parseUploadBlockSyntax(value = '') {
|
||||
const matched = String(value || '').trim().match(SYNTAX_RE)
|
||||
if (!matched) return null
|
||||
return parseUploadBlockInner(matched[1])
|
||||
}
|
||||
|
||||
export function serializeUploadBlockSyntax(allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
||||
const normalized = normalizeUploadBlockTypes(allowedTypes)
|
||||
const isDefault =
|
||||
normalized.length === DEFAULT_UPLOAD_BLOCK_TYPES.length &&
|
||||
normalized.every((type, index) => type === DEFAULT_UPLOAD_BLOCK_TYPES[index])
|
||||
|
||||
if (isDefault) return '{{{}}}'
|
||||
|
||||
const serialized = normalized
|
||||
.map((type) => (type === 'images' ? '[images]' : type))
|
||||
.join(',')
|
||||
|
||||
return `{{{upload file type:${serialized}}}}`
|
||||
}
|
||||
|
||||
export function isUploadBlockImageFile(file) {
|
||||
if (!file) return false
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
const type = String(file.type || '').toLowerCase()
|
||||
return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
|
||||
}
|
||||
|
||||
export function getUploadBlockFileType(file) {
|
||||
if (!file) return ''
|
||||
if (isUploadBlockImageFile(file)) return 'images'
|
||||
|
||||
const name = String(file.name || '').toLowerCase()
|
||||
if (name.endsWith('.docx')) return 'docx'
|
||||
if (name.endsWith('.pptx')) return 'pptx'
|
||||
if (name.endsWith('.pdf')) return 'pdf'
|
||||
if (name.endsWith('.json')) return 'json'
|
||||
if (name.endsWith('.toml')) return 'toml'
|
||||
if (name.endsWith('.yaml') || name.endsWith('.yml')) return 'yaml'
|
||||
if (name.endsWith('.txt')) return 'txt'
|
||||
|
||||
const mime = String(file.type || '').toLowerCase()
|
||||
if (mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return 'docx'
|
||||
if (mime === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') return 'pptx'
|
||||
if (mime === 'application/pdf') return 'pdf'
|
||||
if (mime === 'application/json') return 'json'
|
||||
if (mime === 'application/toml' || mime === 'text/toml') return 'toml'
|
||||
if (mime === 'text/yaml' || mime === 'text/x-yaml' || mime === 'application/x-yaml') return 'yaml'
|
||||
if (mime === 'text/plain') return 'txt'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isUploadBlockTypeAllowed(file, allowedTypes = DEFAULT_UPLOAD_BLOCK_TYPES) {
|
||||
const fileType = getUploadBlockFileType(file)
|
||||
if (!fileType) return false
|
||||
return normalizeUploadBlockTypes(allowedTypes).includes(fileType)
|
||||
}
|
||||
Reference in New Issue
Block a user