test(backend): add comprehensive test coverage for backend modules

Added a new `.coveragerc` file configuring coverage thresholds and exclusions.
Included `pytest.ini` to enable coverage reporting for multiple backend modules (`main`, `llm`, `prompt`, `geoip`, `tts_asr`) with a 90 % fail‑under requirement and detailed HTML output.
Implemented a suite of unit tests:

* `test_geoip.py` – validates geo‑location lookup logic.
* `test_llm_extended.py` – tests LLm response extraction and Ollama interactions.
* `test_main_endpoints.py` – covers API endpoints for completions, OCR, and TTS.
* `test_prompt_extended.py` – verifies language sanitization, timestamp generation, and prompt building.
* `test_tts_asr_coverage.py` – checks device detection, cache clearing, and model loading under various environment configurations.
* `test_tts_asr_extended.py` – further tests TTS/ASR device selection and time‑outs.

Updated `backend/requirements.txt` to use newer, compatible packages, removed obsolete testing dependencies, and added `qwen-tts`.
Modified `backend/tts_asr.py` to work with the new `Qwen3TTSModel`, simplified imports, and adjusted device mapping logic.

Additionally, frontend changes added a new `TreeNodeItem` component, updated Markdown rendering, added TTS instruction fields, and reworked context menu handling.

No breaking changes were introduced.
This commit is contained in:
2026-04-07 23:38:23 +08:00
parent bece7be267
commit 2fdc996af9
19 changed files with 3420 additions and 2325 deletions
+527 -207
View File
@@ -1,29 +1,188 @@
import { ref, computed, watch } from 'vue'
import { computed, ref } from 'vue'
const STORAGE_KEY = 'llm-in-text-file-system'
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
const MAX_FILES = 100
const MAX_FOLDERS = 50
const DB_NAME = 'llm-in-text-docs'
const DB_VERSION = 1
const STORE_NAME = 'nodes'
const MAX_FILE_SIZE = 1024 * 1024 * 1024
const MAX_TEXT_SIZE = 8 * 1024 * 1024
const PREVIEW_TEXT_SIZE = 2 * 1024 * 1024
const MAX_NODES = 5000
let dbPromise = null
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9)
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function countFilesAndFolders(nodes) {
let files = 0
let folders = 0
function traverse(items) {
for (const item of items) {
if (item.type === 'folder') {
folders++
traverse(item.children || [])
} else {
files++
function openDatabase() {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error || new Error('打开本地数据库失败'))
})
return dbPromise
}
async function withStore(mode, handler) {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode)
const store = transaction.objectStore(STORE_NAME)
let result
try {
result = handler(store)
} catch (error) {
reject(error)
return
}
transaction.oncomplete = () => resolve(result)
transaction.onerror = () => reject(transaction.error || new Error('本地数据库写入失败'))
transaction.onabort = () => reject(transaction.error || new Error('本地数据库操作已取消'))
})
}
function cloneRecord(record) {
if (!record) return record
return {
...record,
children: undefined
}
}
function getExtension(name = '') {
const parts = String(name).split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function isTextExtension(ext) {
return [
'md',
'markdown',
'txt',
'json',
'js',
'jsx',
'ts',
'tsx',
'css',
'scss',
'less',
'html',
'htm',
'py',
'vue',
'xml',
'yaml',
'yml',
'csv',
'log',
'sql',
'toml',
'ini',
'cfg',
'conf',
'sh',
'bat',
'ps1',
'java',
'c',
'cpp',
'h',
'hpp',
'go',
'rs'
].includes(ext)
}
function inferMimeType(name, fallback = '') {
const ext = getExtension(name)
const map = {
md: 'text/markdown',
markdown: 'text/markdown',
txt: 'text/plain',
json: 'application/json',
js: 'text/javascript',
jsx: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
css: 'text/css',
html: 'text/html',
htm: 'text/html',
py: 'text/x-python',
vue: 'text/plain',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
log: 'text/plain',
sql: 'text/plain',
toml: 'text/plain',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
sh: 'text/plain',
bat: 'text/plain',
ps1: 'text/plain',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf'
}
return fallback || map[ext] || 'application/octet-stream'
}
function isTextFile(record) {
const ext = getExtension(record?.name)
const mime = String(record?.mimeType || '')
return isTextExtension(ext) || mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')
}
function buildTree(records) {
const map = new Map()
const roots = []
for (const record of records) {
map.set(record.id, {
...record,
children: record.type === 'folder' ? [] : undefined
})
}
for (const record of records) {
const current = map.get(record.id)
if (!record.parentId) {
roots.push(current)
continue
}
const parent = map.get(record.parentId)
if (parent?.type === 'folder') {
parent.children.push(current)
} else {
roots.push(current)
}
}
const sorter = (a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'base' })
}
const sortChildren = (nodes) => {
nodes.sort(sorter)
for (const node of nodes) {
if (node.type === 'folder' && Array.isArray(node.children)) {
sortChildren(node.children)
}
}
}
traverse(nodes)
return { files, folders }
sortChildren(roots)
return roots
}
function findNode(nodes, id) {
@@ -37,26 +196,6 @@ function findNode(nodes, id) {
return null
}
function findParent(nodes, id, parent = null) {
for (const node of nodes) {
if (node.id === id) return parent
if (node.type === 'folder') {
const found = findParent(node.children || [], id, node)
if (found !== undefined) return found
}
}
return undefined
}
function removeNode(nodes, id) {
return nodes.filter(n => n.id !== id).map(n => {
if (n.type === 'folder') {
return { ...n, children: removeNode(n.children || [], id) }
}
return n
})
}
function getPath(nodes, id, path = []) {
for (const node of nodes) {
if (node.id === id) return [...path, node]
@@ -68,235 +207,360 @@ function getPath(nodes, id, path = []) {
return null
}
function estimateRecordSize(record) {
if (typeof record?.size === 'number') return record.size
if (typeof record?.content === 'string') return new Blob([record.content]).size
if (typeof record?.previewText === 'string') return new Blob([record.previewText]).size
return 0
}
async function readFilePayload(file) {
const mimeType = inferMimeType(file.name, file.type)
const ext = getExtension(file.name)
const textFile = isTextExtension(ext) || mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')
if (!textFile) {
return {
mimeType,
size: file.size,
storageKind: 'blob',
blob: file
}
}
if (file.size <= MAX_TEXT_SIZE) {
const content = await file.text()
return {
mimeType,
size: file.size,
storageKind: 'text',
content,
previewText: content,
isTruncatedPreview: false
}
}
const previewText = await file.slice(0, PREVIEW_TEXT_SIZE).text()
return {
mimeType,
size: file.size,
storageKind: 'blob',
blob: file,
previewText,
isTruncatedPreview: true
}
}
function createWelcomeRecords() {
const folderId = generateId()
const fileId = generateId()
const now = Date.now()
return [
{
id: folderId,
name: '示例文件夹',
type: 'folder',
parentId: null,
createdAt: now,
updatedAt: now
},
{
id: fileId,
name: '欢迎使用.md',
type: 'file',
parentId: null,
createdAt: now,
updatedAt: now,
mimeType: 'text/markdown',
storageKind: 'text',
size: 258,
content: [
'# 欢迎使用文档模式',
'',
'这里已经切换为更接近 GitHub 的文件浏览体验。',
'',
'## 现在支持',
'',
'- 左侧文件树与快速上传',
'- 浏览器本地持久化存储',
'- 文本、Markdown、图片、PDF 预览',
'- 大文件保留原始文件并显示截断预览'
].join('\n'),
previewText: '',
isTruncatedPreview: false
}
]
}
export function useFileSystem() {
const tree = ref([])
const records = ref([])
const selectedId = ref(null)
const expandedIds = ref(new Set())
const clipboard = ref(null) // { mode: 'copy' | 'cut', node: {...} }
const contextMenu = ref(null) // { x, y, node }
const clipboard = ref(null)
const contextMenu = ref(null)
const error = ref(null)
const loading = ref(false)
function load() {
const tree = computed(() => buildTree(records.value))
const stats = computed(() => {
let fileCount = 0
let folderCount = 0
let usedBytes = 0
for (const record of records.value) {
if (record.type === 'folder') folderCount += 1
else fileCount += 1
usedBytes += estimateRecordSize(record)
}
return { fileCount, folderCount, usedBytes }
})
async function load() {
loading.value = true
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
tree.value = JSON.parse(stored)
const stored = await withStore('readonly', (store) => store.getAll())
const request = stored
const nextRecords = await new Promise((resolve, reject) => {
request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : [])
request.onerror = () => reject(request.error || new Error('读取本地文件失败'))
})
if (nextRecords.length === 0) {
const seed = createWelcomeRecords()
await Promise.all(seed.map((record) => persistRecord(record)))
records.value = seed
} else {
// 创建示例文件和文件夹
const welcomeId = generateId()
const folderId = generateId()
tree.value = [
{
id: folderId,
name: '示例文件夹',
type: 'folder',
children: [],
parentId: null,
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: welcomeId,
name: '欢迎使用.md',
type: 'file',
content: '# 欢迎使用文件系统\n\n这是一个类似 GitHub 风格的文件浏览器。\n\n## 功能\n\n- ✅ 文件夹展开/折叠\n- ✅ 文件选中高亮\n- ✅ 拖拽移动\n- ✅ 右键菜单\n- ✅ 重命名\n- ✅ 新建/删除\n\n点击左侧的文件或文件夹来查看内容。\n',
parentId: null,
createdAt: Date.now(),
updatedAt: Date.now()
}
]
save()
records.value = nextRecords
}
error.value = null
} catch {
tree.value = []
error.value = '读取本地文件失败,请刷新页面后重试'
records.value = []
} finally {
loading.value = false
}
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tree.value))
} catch {
error.value = '存储空间不足'
}
async function persistRecord(record) {
return withStore('readwrite', (store) => store.put(cloneRecord(record)))
}
watch(tree, save, { deep: true })
async function deleteRecord(id) {
return withStore('readwrite', (store) => store.delete(id))
}
function createFile(parentId, name, content = '') {
const { files } = countFilesAndFolders(tree.value)
if (files >= MAX_FILES) {
error.value = `文件数量已达上限(${MAX_FILES}个)`
function touchParent(parentId) {
if (!parentId) return
const parent = records.value.find((item) => item.id === parentId)
if (!parent) return
parent.updatedAt = Date.now()
persistRecord(parent).catch(() => {
error.value = '更新目录时间失败'
})
}
function createFile(parentId, name, content = '', options = {}) {
if (records.value.length >= MAX_NODES) {
error.value = `文件数量不能超过 ${MAX_NODES}`
return false
}
if (new Blob([content]).size > MAX_FILE_SIZE) {
error.value = '文件大小不能超过 50MB'
const size = typeof options.size === 'number' ? options.size : new Blob([content]).size
if (size > MAX_FILE_SIZE) {
error.value = '单个文件不能超过 1GB'
return false
}
const newFile = {
const now = Date.now()
const file = {
id: generateId(),
name,
type: 'file',
content,
parentId,
createdAt: Date.now(),
updatedAt: Date.now()
parentId: parentId || null,
createdAt: now,
updatedAt: now,
mimeType: inferMimeType(name, options.mimeType),
storageKind: options.storageKind || 'text',
size,
content: options.content ?? content,
previewText: options.previewText ?? '',
isTruncatedPreview: Boolean(options.isTruncatedPreview),
blob: options.blob || null
}
if (!parentId) {
tree.value.push(newFile)
} else {
const parent = findNode(tree.value, parentId)
if (parent && parent.type === 'folder') {
parent.children = parent.children || []
parent.children.push(newFile)
}
records.value = [...records.value, file]
if (parentId) {
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
selectedId.value = file.id
error.value = null
persistRecord(file).catch(() => {
error.value = '保存文件失败,可能是浏览器存储空间不足'
})
touchParent(parentId)
return true
}
function createFolder(parentId, name) {
const { folders } = countFilesAndFolders(tree.value)
if (folders >= MAX_FOLDERS) {
error.value = `文件夹数量已达上限(${MAX_FOLDERS}个)`
if (records.value.length >= MAX_NODES) {
error.value = `目录项数量不能超过 ${MAX_NODES}`
return false
}
const newFolder = {
const now = Date.now()
const folder = {
id: generateId(),
name,
type: 'folder',
children: [],
parentId,
createdAt: Date.now(),
updatedAt: Date.now()
parentId: parentId || null,
createdAt: now,
updatedAt: now
}
if (!parentId) {
tree.value.push(newFolder)
} else {
const parent = findNode(tree.value, parentId)
if (parent && parent.type === 'folder') {
parent.children = parent.children || []
parent.children.push(newFolder)
}
records.value = [...records.value, folder]
if (parentId) {
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
selectedId.value = folder.id
error.value = null
persistRecord(folder).catch(() => {
error.value = '保存文件夹失败'
})
touchParent(parentId)
return true
}
function rename(id, newName) {
const node = findNode(tree.value, id)
if (node) {
node.name = newName
node.updatedAt = Date.now()
error.value = null
return true
const node = records.value.find((item) => item.id === id)
if (!node) return false
node.name = newName
node.updatedAt = Date.now()
error.value = null
persistRecord(node).catch(() => {
error.value = '重命名失败'
})
return true
}
function collectDescendantIds(id) {
const ids = new Set([id])
let changed = true
while (changed) {
changed = false
for (const record of records.value) {
if (record.parentId && ids.has(record.parentId) && !ids.has(record.id)) {
ids.add(record.id)
changed = true
}
}
}
return false
return [...ids]
}
function remove(id) {
tree.value = removeNode(tree.value, id)
if (selectedId.value === id) selectedId.value = null
const ids = new Set(collectDescendantIds(id))
const deletingSelected = selectedId.value && ids.has(selectedId.value)
records.value = records.value.filter((item) => !ids.has(item.id))
if (deletingSelected) selectedId.value = null
if (clipboard.value?.nodeId && ids.has(clipboard.value.nodeId)) {
clipboard.value = null
}
if (clipboard.value?.node?.id && ids.has(clipboard.value.node.id)) {
clipboard.value = null
}
error.value = null
ids.forEach((currentId) => {
deleteRecord(currentId).catch(() => {
error.value = '删除文件失败'
})
})
}
function select(id) {
selectedId.value = id
}
function select(id) {
selectedId.value = id
}
function toggleFolder(id) {
const node = findNode(tree.value, id)
const node = records.value.find((item) => item.id === id)
if (!node || node.type !== 'folder') return
const set = new Set(expandedIds.value)
if (set.has(id)) {
set.delete(id)
} else {
set.add(id)
}
expandedIds.value = set
const next = new Set(expandedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedIds.value = next
}
function copy(id) {
const node = findNode(tree.value, id)
if (node) {
clipboard.value = { mode: 'copy', node: JSON.parse(JSON.stringify(node)) }
if (!node) return
clipboard.value = {
mode: 'copy',
node: JSON.parse(JSON.stringify(node))
}
}
function cut(id) {
const node = findNode(tree.value, id)
if (node) {
clipboard.value = { mode: 'cut', node: JSON.parse(JSON.stringify(node)) }
const node = records.value.find((item) => item.id === id)
if (!node) return
clipboard.value = {
mode: 'cut',
nodeId: node.id
}
}
function isDescendantOf(sourceId, targetParentId) {
let current = records.value.find((item) => item.id === targetParentId)
while (current) {
if (current.parentId === sourceId) return true
current = current.parentId ? records.value.find((item) => item.id === current.parentId) : null
}
return false
}
function duplicateNode(node, targetParentId) {
const now = Date.now()
const clonedId = generateId()
const record = {
...cloneRecord(node),
id: clonedId,
parentId: targetParentId,
createdAt: now,
updatedAt: now
}
records.value = [...records.value, record]
persistRecord(record).catch(() => {
error.value = '复制文件失败'
})
if (node.type === 'folder') {
for (const child of node.children || []) {
duplicateNode(child, clonedId)
}
}
}
function paste(targetParentId) {
if (!clipboard.value) return
const { mode, node } = clipboard.value
if (mode === 'cut') {
const oldParent = findParent(tree.value, node.id)
if (oldParent) {
oldParent.children = (oldParent.children || []).filter(c => c.id !== node.id)
} else {
tree.value = tree.value.filter(n => n.id !== node.id)
if (clipboard.value.mode === 'cut') {
const node = records.value.find((item) => item.id === clipboard.value.nodeId)
if (!node) {
clipboard.value = null
return
}
if (node.id === targetParentId || (targetParentId && isDescendantOf(node.id, targetParentId))) {
error.value = '不能移动到自身或子目录中'
return
}
node.parentId = targetParentId || null
if (targetParentId) {
const target = findNode(tree.value, targetParentId)
if (target && target.type === 'folder') {
target.children = target.children || []
target.children.push(node)
}
} else {
tree.value.push(node)
}
node.updatedAt = Date.now()
persistRecord(node).catch(() => {
error.value = '移动文件失败'
})
touchParent(targetParentId)
clipboard.value = null
} else {
const { files, folders } = countFilesAndFolders(tree.value)
function countInNode(n) {
let f = 0, fl = 0
if (n.type === 'folder') {
fl = 1
for (const c of (n.children || [])) {
const sub = countInNode(c)
f += sub.f
fl += sub.fl
}
} else {
f = 1
}
return { f, fl }
}
const counts = countInNode(node)
if (files + counts.f > MAX_FILES) {
error.value = `文件数量将达上限`
return
}
if (folders + counts.fl > MAX_FOLDERS) {
error.value = `文件夹数量将达上限`
return
}
function cloneWithNewIds(n) {
const clone = { ...n, id: generateId(), createdAt: Date.now(), updatedAt: Date.now() }
if (clone.type === 'folder') {
clone.children = (n.children || []).map(c => cloneWithNewIds(c))
}
return clone
}
const cloned = cloneWithNewIds(node)
cloned.parentId = targetParentId || null
if (targetParentId) {
const target = findNode(tree.value, targetParentId)
if (target && target.type === 'folder') {
target.children = target.children || []
target.children.push(cloned)
}
} else {
tree.value.push(cloned)
}
error.value = null
return
}
const source = clipboard.value.node
if (!source) return
if (records.value.length >= MAX_NODES) {
error.value = `目录项数量不能超过 ${MAX_NODES}`
return
}
duplicateNode(source, targetParentId || null)
touchParent(targetParentId)
error.value = null
}
@@ -324,21 +588,20 @@ function select(id) {
contextMenu.value = null
}
function getExtension(name) {
const parts = name.split('.')
return parts.length > 1 ? parts.pop().toLowerCase() : ''
}
function getFileIcon(name) {
const ext = getExtension(name)
const iconMap = {
md: 'markdown',
markdown: 'markdown',
txt: 'text',
json: 'json',
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
css: 'css',
html: 'html',
htm: 'html',
py: 'python',
vue: 'vue',
xml: 'xml',
@@ -346,11 +609,64 @@ function select(id) {
yml: 'yaml',
csv: 'csv',
log: 'log',
sql: 'sql'
sql: 'sql',
jpg: 'image',
jpeg: 'image',
png: 'image',
gif: 'image',
webp: 'image',
svg: 'image',
pdf: 'pdf',
doc: 'word',
docx: 'word',
ppt: 'ppt',
pptx: 'ppt',
xls: 'excel',
xlsx: 'excel',
zip: 'zip'
}
return iconMap[ext] || 'file'
}
async function uploadFiles(files, parentId = null) {
const source = Array.from(files || [])
if (source.length === 0) return { success: 0, failed: [] }
const failed = []
let success = 0
for (const file of source) {
if (file.size > MAX_FILE_SIZE) {
failed.push({ name: file.name, reason: '单个文件不能超过 1GB' })
continue
}
try {
const payload = await readFilePayload(file)
const created = createFile(parentId, file.name, payload.content || '', payload)
if (created) success += 1
else failed.push({ name: file.name, reason: error.value || '创建文件失败' })
} catch {
failed.push({ name: file.name, reason: '读取文件失败' })
}
}
if (success > 0 && parentId) {
const next = new Set(expandedIds.value)
next.add(parentId)
expandedIds.value = next
}
return { success, failed }
}
function getFileBlob(node) {
if (!node || node.type !== 'file') return null
if (node.blob instanceof Blob) return node.blob
if (typeof node.content === 'string') {
return new Blob([node.content], { type: inferMimeType(node.name, node.mimeType) })
}
if (typeof node.previewText === 'string' && node.previewText) {
return new Blob([node.previewText], { type: inferMimeType(node.name, node.mimeType) })
}
return null
}
return {
tree,
selectedId,
@@ -358,6 +674,8 @@ function select(id) {
clipboard,
contextMenu,
error,
loading,
stats,
load,
createFile,
createFolder,
@@ -376,8 +694,10 @@ function select(id) {
hideContextMenu,
getFileIcon,
getExtension,
getFileBlob,
isTextFile,
uploadFiles,
MAX_FILE_SIZE,
MAX_FILES,
MAX_FOLDERS
MAX_NODES
}
}