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:
2026-05-24 23:30:32 +08:00
parent 6dc9933853
commit 59334e4057
41 changed files with 4438 additions and 4875 deletions
+177 -13
View File
@@ -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',
+1
View File
@@ -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'
+162
View File
@@ -0,0 +1,162 @@
export const HIDDEN_TEXT_NODE_TYPE = 'hiddenText'
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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)
}
}
+54
View File
@@ -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: '取消',
+72
View File
@@ -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
}
+220
View File
@@ -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)
}