feat(web): 设置中心缓存管理与文件预览资产工具

- 新增 documentPreviewAssets 工具,统一从 URL/Blob/File 推断预览类型(image/pdf/file/unsupported)
- SettingsView/SettingsView.js/settingsModelHelper 新增系统缓存管理区块,调用 /settings/cache/clear 并展示清理结果;useSettings/services 适配
- WorkbenchAiFilePreviewDialog/useWorkbenchAiFilePreview 接入预览资产工具,workbenchAiComposerModel 调整文件处理
- ReceiptFolder/LogDetailView/DigitalEmployeeWorkRecords/travelReimbursementAttachmentModel 配套适配
- 新增 settings-cache-management-section 测试,更新 settings-llm/rendering/receipt-folder-view/composer-components/attachment-association 测试
This commit is contained in:
caoxiaozhu
2026-06-24 12:35:59 +08:00
parent 9a5ed0e94a
commit 8417a9f542
20 changed files with 815 additions and 102 deletions

View File

@@ -687,7 +687,243 @@
} }
.rendering-settings-card .switch-group { .rendering-settings-card .switch-group {
margin-bottom: 20px; margin-bottom: 24px;
}
.cache-management-card {
display: grid;
gap: 16px;
}
.cache-management-hero-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(92px, 1fr));
gap: 8px;
}
.cache-management-hero-metrics span {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.cache-management-hero-metrics strong {
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.1;
}
.cache-management-hero-metrics small {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.cache-safety-strip {
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
color: #475569;
font-size: 13px;
font-weight: 700;
line-height: 1.5;
}
.cache-safety-strip i {
flex: 0 0 auto;
font-size: 18px;
}
.cache-scope-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.cache-scope-item {
min-width: 0;
min-height: 148px;
display: grid;
align-content: start;
gap: 8px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.cache-scope-item i {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 6px;
background: #f1f5f9;
color: #475569;
font-size: 17px;
}
.cache-scope-item strong {
color: #0f172a;
font-size: 14px;
font-weight: 800;
line-height: 1.35;
}
.cache-scope-item span {
color: #64748b;
font-size: 12.5px;
font-weight: 600;
line-height: 1.55;
}
.cache-management-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 16px 18px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
}
.cache-management-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.cache-management-copy strong {
color: #0f172a;
font-size: 15px;
font-weight: 800;
line-height: 1.35;
}
.cache-management-copy span {
color: #64748b;
font-size: 12.5px;
font-weight: 650;
line-height: 1.5;
}
.cache-clear-button {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #334155;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
transition: all 0.2s ease;
cursor: pointer;
}
.cache-clear-button:hover:not(:disabled) {
border-color: var(--theme-primary);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.cache-clear-button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.cache-clear-result,
.cache-clear-empty {
padding: 14px 16px;
border: 1px solid #dbe4ee;
border-radius: 6px;
background: #ffffff;
}
.cache-clear-result.is-error {
border-color: #fecaca;
background: #fff7f7;
}
.cache-clear-result-head {
display: flex;
align-items: center;
gap: 8px;
}
.cache-clear-result-head i {
color: #16a34a;
font-size: 18px;
}
.cache-clear-result.is-error .cache-clear-result-head i {
color: #dc2626;
}
.cache-clear-result-head strong {
color: #0f172a;
font-size: 13.5px;
font-weight: 800;
line-height: 1.4;
}
.cache-clear-result ul {
display: grid;
gap: 8px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.cache-clear-result li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 8px;
border-top: 1px solid #eef2f7;
color: #475569;
font-size: 12.5px;
font-weight: 650;
}
.cache-clear-result li strong {
color: #0f172a;
font-weight: 800;
white-space: nowrap;
}
.cache-clear-empty {
display: inline-flex;
align-items: center;
gap: 8px;
justify-self: start;
color: #64748b;
font-size: 12.5px;
font-weight: 650;
}
.cache-clear-empty i {
color: var(--theme-primary);
font-size: 15px;
} }
.log-policy-card .card-head { .log-policy-card .card-head {
@@ -751,6 +987,15 @@
.save-button { .save-button {
justify-content: center; justify-content: center;
} }
.cache-management-panel {
grid-template-columns: 1fr;
align-items: stretch;
}
.cache-scope-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@@ -801,6 +1046,21 @@
justify-items: start; justify-items: start;
padding: 20px; padding: 20px;
} }
.cache-management-hero-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-management-panel {
flex-direction: column;
align-items: stretch;
}
.cache-clear-button {
width: 100%;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -850,4 +1110,16 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.cache-scope-item,
.cache-management-panel,
.cache-clear-result,
.cache-clear-empty {
border-radius: 6px;
}
.cache-scope-grid,
.cache-management-hero-metrics {
grid-template-columns: 1fr;
}
} }

View File

@@ -250,10 +250,6 @@
</template> </template>
<template #actions> <template #actions>
<button class="minor-action" type="button" :disabled="!selectedRunDetail?.run_id" @click="openTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail"> <button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i> <i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span> <span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
@@ -266,7 +262,6 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AuditPickerFilter from './AuditPickerFilter.vue' import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue' import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
@@ -303,7 +298,6 @@ const props = defineProps({
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change']) const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast() const { toast } = useToast()
const router = useRouter()
const runs = ref([]) const runs = ref([])
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
@@ -586,14 +580,6 @@ function closeWorkRecordDetail() {
detailError.value = '' detailError.value = ''
} }
function openTraceCenter() {
const runId = String(selectedRunDetail.value?.run_id || selectedRunId.value || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch( watch(
() => props.focusRunId, () => props.focusRunId,
(runId) => { (runId) => {

View File

@@ -36,6 +36,10 @@
class="workbench-ai-file-preview-frame" class="workbench-ai-file-preview-frame"
title="附件 PDF 预览" title="附件 PDF 预览"
></iframe> ></iframe>
<div v-else-if="preview.sourceKind === 'loading'" class="workbench-ai-file-preview-state">
<i class="mdi mdi-loading"></i>
<span>正在加载统一预览</span>
</div>
<div v-else class="workbench-ai-file-preview-state"> <div v-else class="workbench-ai-file-preview-state">
<i class="mdi mdi-file-eye-outline"></i> <i class="mdi mdi-file-eye-outline"></i>
<span>当前附件暂不支持直接预览</span> <span>当前附件暂不支持直接预览</span>

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from './useSystemState.js' import { useSystemState } from './useSystemState.js'
import { useThemeSkin } from './useThemeSkin.js' import { useThemeSkin } from './useThemeSkin.js'
import { fetchSettings, saveSettings } from '../services/settings.js' import { clearSystemCaches, fetchSettings, saveSettings } from '../services/settings.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { import {
isHermesEmployeeSettingsReady isHermesEmployeeSettingsReady
@@ -56,6 +56,10 @@ export function useSettings() {
const sessionRetentionPickerOpen = ref(false) const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null) const sessionRetentionPickerRef = ref(null)
const logoInputRef = ref(null) const logoInputRef = ref(null)
const cacheClearing = ref(false)
const cacheClearItems = ref([])
const cacheClearMessage = ref('')
const cacheClearFailed = ref(false)
const sections = SECTION_DEFINITIONS const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS const logLevels = LOG_LEVELS
@@ -433,6 +437,46 @@ export function useSettings() {
}) })
} }
function normalizeCacheClearErrorMessage(error) {
const message = String(error?.message || '').trim()
if (!message || /^not found$/i.test(message)) {
return '缓存清理接口暂不可用,请确认后端服务已加载最新路由后重试。'
}
return message
}
async function clearAllCaches() {
if (cacheClearing.value) {
return
}
cacheClearing.value = true
cacheClearMessage.value = ''
cacheClearItems.value = []
cacheClearFailed.value = false
try {
const payload = await clearSystemCaches()
const items = Array.isArray(payload?.items) ? payload.items : []
const totalCleared = Number(payload?.totalCleared || 0)
cacheClearItems.value = items
cacheClearMessage.value = totalCleared > 0
? `已清理 ${totalCleared} 条缓存。`
: '当前没有可清理的缓存。'
cacheClearFailed.value = false
toast(cacheClearMessage.value)
} catch (error) {
const message = normalizeCacheClearErrorMessage(error)
cacheClearFailed.value = true
cacheClearMessage.value = message
toast(message)
} finally {
cacheClearing.value = false
}
}
async function saveMailSection() { async function saveMailSection() {
const mailForm = pageState.value.mailForm const mailForm = pageState.value.mailForm
@@ -494,6 +538,10 @@ export function useSettings() {
return return
} }
if (activeSection.value === 'cacheManagement') {
return
}
if (activeSection.value === 'rendering') { if (activeSection.value === 'rendering') {
await saveRenderingSection() await saveRenderingSection()
return return
@@ -557,6 +605,11 @@ export function useSettings() {
activeThemeSkinId, activeThemeSkinId,
archiveCycleOptions, archiveCycleOptions,
activateSection, activateSection,
cacheClearFailed,
cacheClearItems,
cacheClearMessage,
cacheClearing,
clearAllCaches,
clearRenderSecretMask, clearRenderSecretMask,
completedSectionCount, completedSectionCount,
logLevels, logLevels,

View File

@@ -1,5 +1,13 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { buildSelectedFileCards } from './workbenchAiComposerModel.js' import { buildSelectedFileCards } from './workbenchAiComposerModel.js'
import { fetchReceiptFolderAsset } from '../../services/receiptFolder.js'
import {
inferPreviewKindFromBlob,
inferPreviewKindFromFile,
isInlinePreviewUrl,
isTemporaryPreviewUrl,
resolveDocumentPreviewAsset
} from '../../utils/documentPreviewAssets.js'
function normalizePreviewText(value) { function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim() return String(value ?? '').replace(/\s+/g, ' ').trim()
@@ -40,10 +48,6 @@ function normalizePreviewField(field = {}) {
} }
} }
function resolveDocumentPreviewUrl(document = null) {
return normalizePreviewText(document?.preview_data_url || document?.previewDataUrl)
}
function resolveSourceKind(sourceUrl, rawFile = {}) { function resolveSourceKind(sourceUrl, rawFile = {}) {
const type = normalizePreviewText(rawFile?.type).toLowerCase() const type = normalizePreviewText(rawFile?.type).toLowerCase()
const name = normalizePreviewText(rawFile?.name).toLowerCase() const name = normalizePreviewText(rawFile?.name).toLowerCase()
@@ -72,8 +76,18 @@ export function useWorkbenchAiFilePreview({
scrollInlineConversationToBottom, scrollInlineConversationToBottom,
selectedFiles selectedFiles
}) { }) {
const filePreviewState = ref({ open: false, key: '', objectUrl: '' }) const filePreviewState = ref({
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({ open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
})
const selectedFileCards = computed(() => buildSelectedFileCards(
selectedFiles.value,
(file) => attachmentFlow.resolveAiModeReceiptRecognitionState(file)
).map((card, index) => ({
...card, ...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index]) ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
}))) })))
@@ -97,22 +111,94 @@ export function useWorkbenchAiFilePreview({
} }
} }
function resolveTargetDocument(target) {
if (!target?.rawFile) {
return null
}
const recognitionState = attachmentFlow.resolveAiModeReceiptRecognitionState(target.rawFile) || target.card.ocrState || null
return recognitionState?.document || null
}
function resolveRemotePreviewAsset(target) {
const asset = resolveDocumentPreviewAsset(resolveTargetDocument(target))
if (!asset?.url || isInlinePreviewUrl(asset.url) || isTemporaryPreviewUrl(asset.url)) {
return null
}
return asset
}
async function loadRemotePreviewAsset(fileKey) {
const target = findSelectedFile(fileKey)
const asset = resolveRemotePreviewAsset(target)
if (!target?.rawFile || !asset?.url) {
return
}
try {
const blob = await fetchReceiptFolderAsset(asset.url)
const objectUrl = createObjectUrl(blob)
const objectKind = inferPreviewKindFromBlob(blob) || asset.kind
if (!objectUrl || !['image', 'pdf'].includes(objectKind)) {
return
}
if (!filePreviewState.value.open || filePreviewState.value.key !== fileKey) {
URL.revokeObjectURL(objectUrl)
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
...filePreviewState.value,
objectUrl,
objectKind,
objectSource: asset.source,
loading: false
}
} catch (error) {
console.warn('AI mode remote attachment preview unavailable:', error)
if (!filePreviewState.value.open || filePreviewState.value.key !== fileKey) {
return
}
clearFilePreviewObjectUrl()
filePreviewState.value = {
...filePreviewState.value,
objectUrl: createObjectUrl(target.rawFile),
objectKind: inferPreviewKindFromFile(target.rawFile),
objectSource: 'file',
loading: false
}
}
}
function openAiModeFilePreview(fileKey) { function openAiModeFilePreview(fileKey) {
const target = findSelectedFile(fileKey) const target = findSelectedFile(fileKey)
if (!target?.rawFile) { if (!target?.rawFile) {
return return
} }
clearFilePreviewObjectUrl() clearFilePreviewObjectUrl()
const remoteAsset = resolveRemotePreviewAsset(target)
filePreviewState.value = { filePreviewState.value = {
open: true, open: true,
key: fileKey, key: fileKey,
objectUrl: createObjectUrl(target.rawFile) objectUrl: remoteAsset ? '' : createObjectUrl(target.rawFile),
objectKind: remoteAsset ? '' : inferPreviewKindFromFile(target.rawFile),
objectSource: remoteAsset ? remoteAsset.source : 'file',
loading: Boolean(remoteAsset)
}
if (remoteAsset) {
void loadRemotePreviewAsset(fileKey)
} }
} }
function closeAiModeFilePreview() { function closeAiModeFilePreview() {
clearFilePreviewObjectUrl() clearFilePreviewObjectUrl()
filePreviewState.value = { open: false, key: '', objectUrl: '' } filePreviewState.value = {
open: false,
key: '',
objectUrl: '',
objectKind: '',
objectSource: '',
loading: false
}
} }
const activeAiModeFilePreview = computed(() => { const activeAiModeFilePreview = computed(() => {
@@ -128,9 +214,16 @@ export function useWorkbenchAiFilePreview({
const document = recognitionState?.document || null const document = recognitionState?.document || null
const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || [] const documentFields = Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || []
const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean) const ocrFields = documentFields.map((field) => normalizePreviewField(field)).filter(Boolean)
const documentPreviewUrl = resolveDocumentPreviewUrl(document) const documentPreviewAsset = resolveDocumentPreviewAsset(document)
const sourceUrl = documentPreviewUrl || filePreviewState.value.objectUrl const inlinePreviewAvailable = documentPreviewAsset?.url && isInlinePreviewUrl(documentPreviewAsset.url)
const sourceKind = documentPreviewUrl ? 'image' : resolveSourceKind(sourceUrl, rawFile) const sourceUrl = inlinePreviewAvailable
? documentPreviewAsset.url
: filePreviewState.value.objectUrl
const sourceKind = inlinePreviewAvailable
? documentPreviewAsset.kind
: filePreviewState.value.loading
? 'loading'
: filePreviewState.value.objectKind || resolveSourceKind(sourceUrl, rawFile)
const documentTypeLabel = normalizePreviewText( const documentTypeLabel = normalizePreviewText(
document?.document_type_label || document?.document_type_label ||
document?.scene_label || document?.scene_label ||

View File

@@ -1,4 +1,5 @@
import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js' import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import { resolveDocumentPreviewAsset } from '../../utils/documentPreviewAssets.js'
export const AI_COMPOSER_FILE_TYPE_META = { export const AI_COMPOSER_FILE_TYPE_META = {
pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' }, pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
@@ -44,7 +45,14 @@ export function resolveAiComposerFileName(file) {
return String(file?.name || '未命名附件').trim() || '未命名附件' return String(file?.name || '未命名附件').trim() || '未命名附件'
} }
export function resolveAiComposerFileType(file) { export function resolveAiComposerFileType(file, previewAsset = null) {
if (previewAsset?.kind === 'image') {
return AI_COMPOSER_FILE_TYPE_META.image
}
if (previewAsset?.kind === 'pdf') {
return AI_COMPOSER_FILE_TYPE_META.pdf
}
const fileName = resolveAiComposerFileName(file).toLowerCase() const fileName = resolveAiComposerFileName(file).toLowerCase()
const mimeType = String(file?.type || '').toLowerCase() const mimeType = String(file?.type || '').toLowerCase()
const extension = fileName.includes('.') ? fileName.split('.').pop() : '' const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
@@ -66,12 +74,19 @@ export function resolveAiComposerFileType(file) {
return AI_COMPOSER_FILE_TYPE_META.file return AI_COMPOSER_FILE_TYPE_META.file
} }
export function buildSelectedFileCards(files = []) { export function buildSelectedFileCards(files = [], resolveRecognitionState = null) {
return files.map((file) => ({ return files.map((file, index) => {
key: buildFileIdentity(file), const recognitionState = typeof resolveRecognitionState === 'function'
name: resolveAiComposerFileName(file), ? resolveRecognitionState(file, index)
...resolveAiComposerFileType(file) : null
})) const previewAsset = resolveDocumentPreviewAsset(recognitionState?.document || null)
return {
key: buildFileIdentity(file),
name: resolveAiComposerFileName(file),
...resolveAiComposerFileType(file, previewAsset),
previewAsset: previewAsset || null
}
})
} }
export function isLikelyAiModeOcrFile(file = {}) { export function isLikelyAiModeOcrFile(file = {}) {

View File

@@ -17,3 +17,9 @@ export function testModelConnectivity(payload) {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
} }
export function clearSystemCaches() {
return apiRequest('/settings/cache/clear', {
method: 'POST'
})
}

View File

@@ -0,0 +1,118 @@
function normalizePreviewText(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim()
}
function normalizePreviewKind(value) {
const normalized = normalizePreviewText(value).toLowerCase()
return ['image', 'pdf', 'file', 'unsupported'].includes(normalized) ? normalized : ''
}
export function inferPreviewKindFromUrl(url) {
const normalized = normalizePreviewText(url).toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpe?g|webp|gif|bmp|svg)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
export function inferPreviewKindFromBlob(blob) {
const mediaType = normalizePreviewText(blob?.type).toLowerCase()
if (mediaType.startsWith('image/')) return 'image'
if (mediaType === 'application/pdf') return 'pdf'
return ''
}
export function inferPreviewKindFromFile(file) {
const mediaType = normalizePreviewText(file?.type).toLowerCase()
const filename = normalizePreviewText(file?.name).toLowerCase()
if (mediaType.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg|heic)$/i.test(filename)) {
return 'image'
}
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
return 'pdf'
}
return 'file'
}
export function isInlinePreviewUrl(url) {
return normalizePreviewText(url).toLowerCase().startsWith('data:')
}
export function isTemporaryPreviewUrl(url) {
return normalizePreviewText(url).toLowerCase().startsWith('blob:')
}
export function resolveDocumentPreviewKind(item = {}, fallback = '') {
const explicit = normalizePreviewKind(item?.preview_kind || item?.previewKind)
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(resolveDocumentPreviewUrl(item)) || normalizePreviewKind(fallback)
}
export function resolveDocumentPreviewUrl(item = {}) {
return normalizePreviewText(
item?.preview_data_url ||
item?.previewDataUrl ||
item?.preview_url ||
item?.previewUrl ||
item?.receipt_preview_url ||
item?.receiptPreviewUrl
)
}
export function resolveDocumentPreviewAsset(item = {}, options = {}) {
const sourceCandidates = [
{
source: 'inline',
url: normalizePreviewText(item?.preview_data_url || item?.previewDataUrl)
},
{
source: 'remote',
url: normalizePreviewText(item?.preview_url || item?.previewUrl)
},
{
source: 'receipt',
url: normalizePreviewText(item?.receipt_preview_url || item?.receiptPreviewUrl)
}
]
const explicitKind = normalizePreviewKind(item?.preview_kind || item?.previewKind)
for (const candidate of sourceCandidates) {
if (!candidate.url) continue
const kind = explicitKind || inferPreviewKindFromUrl(candidate.url)
if (!['image', 'pdf'].includes(kind)) continue
return {
kind,
url: candidate.url,
source: candidate.source
}
}
const fallbackUrl = normalizePreviewText(options.fallbackUrl)
if (fallbackUrl) {
const kind = inferPreviewKindFromUrl(fallbackUrl) || inferPreviewKindFromFile(options.fallbackFile)
if (['image', 'pdf'].includes(kind)) {
return {
kind,
url: fallbackUrl,
source: 'file'
}
}
}
const fallbackKind = inferPreviewKindFromFile(options.fallbackFile)
if (fallbackKind) {
return {
kind: fallbackKind,
url: '',
source: 'file'
}
}
return null
}

View File

@@ -84,11 +84,11 @@ export const SECTION_DEFINITIONS = [
actionLabel: '' actionLabel: ''
}, },
{ {
id: 'agentTraces', id: 'cacheManagement',
label: 'Agent Trace', label: '缓存管理',
title: 'Agent 链路追踪', title: '系统缓存管理',
desc: '对话链路、工具调用与事件重放', desc: 'OCR、模型与索引缓存',
longDesc: '按 Run ID 还原 Orchestrator 到下游 Agent 的语义识别、路由、工具调用、会话写回和最终回复,便于线上排障和审计复盘。', longDesc: '手动清理 OCR 识别结果、模型失败冷却、知识库本地索引和运行时配置等进程内缓存。',
actionLabel: '' actionLabel: ''
}, },
{ {
@@ -482,7 +482,7 @@ export function computeSectionStatus(state) {
normalizeValue(state.logForm.logPath) normalizeValue(state.logForm.logPath)
), ),
systemLogs: true, systemLogs: true,
agentTraces: true, cacheManagement: true,
mail: Boolean( mail: Boolean(
normalizeValue(state.mailForm.smtpHost) && normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 && Number(state.mailForm.port) > 0 &&

View File

@@ -34,10 +34,6 @@
<i class="mdi mdi-refresh"></i> <i class="mdi mdi-refresh"></i>
<span>刷新详情</span> <span>刷新详情</span>
</button> </button>
<button class="refresh-btn" type="button" @click="openAgentTraceCenter">
<i class="mdi mdi-timeline-text-outline"></i>
<span>查看 Trace</span>
</button>
</div> </div>
</article> </article>
@@ -445,14 +441,6 @@ function backToLogs() {
router.push({ name: 'app-settings', query: { section: 'systemLogs' } }) router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
} }
function openAgentTraceCenter() {
const runId = String(hermesRun.value?.run_id || '').trim()
if (!runId) {
return
}
router.push({ name: 'app-settings', query: { section: 'agentTraces', run_id: runId } })
}
watch( watch(
() => [route.params.logKind, route.params.logId], () => [route.params.logKind, route.params.logId],
() => { () => {

View File

@@ -381,6 +381,7 @@ import {
fetchReceiptFolderItems, fetchReceiptFolderItems,
updateReceiptFolderItem updateReceiptFolderItem
} from '../services/receiptFolder.js' } from '../services/receiptFolder.js'
import { inferPreviewKindFromBlob } from '../utils/documentPreviewAssets.js'
import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js' import { createReceiptDetailDashboardModel } from './scripts/receiptFolderDetailDashboard.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js' import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js' import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListFilters.js'
@@ -638,6 +639,13 @@ async function loadPreview(detail) {
if (!detail?.preview_url) return if (!detail?.preview_url) return
try { try {
const blob = await fetchReceiptFolderAsset(detail.preview_url) const blob = await fetchReceiptFolderAsset(detail.preview_url)
const resolvedPreviewKind = inferPreviewKindFromBlob(blob)
if (resolvedPreviewKind && selectedReceipt.value?.id === detail.id) {
selectedReceipt.value = {
...selectedReceipt.value,
preview_kind: resolvedPreviewKind
}
}
previewObjectUrl.value = URL.createObjectURL(blob) previewObjectUrl.value = URL.createObjectURL(blob)
} catch { } catch {
previewObjectUrl.value = '' previewObjectUrl.value = ''

View File

@@ -45,7 +45,7 @@
<div <div
class="settings-content" class="settings-content"
:class="{ 'settings-content-fill': ['systemLogs', 'agentTraces'].includes(activeSection) }" :class="{ 'settings-content-fill': ['systemLogs'].includes(activeSection) }"
> >
<template v-if="activeSection === 'profile'"> <template v-if="activeSection === 'profile'">
<section class="settings-card"> <section class="settings-card">
@@ -442,8 +442,95 @@
<LogsView v-else class="settings-logs-view" /> <LogsView v-else class="settings-logs-view" />
</template> </template>
<template v-else-if="activeSection === 'agentTraces'"> <template v-else-if="activeSection === 'cacheManagement'">
<AgentTraceCenterView class="settings-trace-center-view" /> <section class="settings-card cache-management-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-cached"></i>
</div>
<div>
<h4>应用缓存清理</h4>
<p>清理运行时产生的临时结果释放进程内缓存并让下一次识别检索或模型调用重新构建最新状态</p>
</div>
</div>
<div class="cache-management-hero-metrics" aria-label="缓存清理范围概览">
<span>
<strong>4 </strong>
<small>清理范围</small>
</span>
<span>
<strong>0 </strong>
<small>数据删除</small>
</span>
</div>
</div>
<div>
<div class="cache-safety-strip">
<i class="mdi mdi-shield-check-outline" aria-hidden="true"></i>
<span>不会删除源文件业务单据和系统日志只清理可重新生成的进程内缓存</span>
</div>
</div>
<div class="cache-scope-grid" aria-label="缓存清理范围">
<article class="cache-scope-item">
<i class="mdi mdi-text-recognition" aria-hidden="true"></i>
<strong>OCR 识别结果</strong>
<span>清空票据识别的内存结果下次上传或查看时重新识别</span>
</article>
<article class="cache-scope-item">
<i class="mdi mdi-robot-outline" aria-hidden="true"></i>
<strong>模型失败冷却</strong>
<span>解除模型通道失败后的短期冷却便于恢复后立即重试</span>
</article>
<article class="cache-scope-item">
<i class="mdi mdi-book-search-outline" aria-hidden="true"></i>
<strong>知识库本地索引</strong>
<span>清理本地检索索引缓存下一次检索会重新读取知识库</span>
</article>
<article class="cache-scope-item">
<i class="mdi mdi-tune-variant" aria-hidden="true"></i>
<strong>运行时配置缓存</strong>
<span>刷新进程内配置读取结果避免继续使用旧配置快照</span>
</article>
</div>
<div>
<div class="cache-management-panel">
<div class="cache-management-copy">
<strong>立即执行维护清理</strong>
<span>建议在 OCR 配置模型接入或知识库文件调整后使用</span>
</div>
<button class="cache-clear-button" type="button" :disabled="cacheClearing" @click="clearAllCaches">
<i class="mdi" :class="cacheClearing ? 'mdi-loading mdi-spin' : 'mdi-delete-sweep-outline'"></i>
<span>{{ cacheClearing ? '清理中...' : '一键清理缓存' }}</span>
</button>
</div>
</div>
<div v-if="cacheClearMessage" class="cache-clear-result" :class="{ 'is-error': cacheClearFailed }">
<div class="cache-clear-result-head">
<i
class="mdi"
:class="cacheClearFailed ? 'mdi-alert-circle-outline' : 'mdi-check-circle-outline'"
aria-hidden="true"
></i>
<strong>{{ cacheClearMessage }}</strong>
</div>
<ul v-if="cacheClearItems.length">
<li v-for="item in cacheClearItems" :key="item.cacheKey">
<span>{{ item.label }}</span>
<strong>{{ item.clearedCount }} </strong>
</li>
</ul>
</div>
<div v-else class="cache-clear-empty">
<i class="mdi mdi-database-clock-outline"></i>
<span>等待执行缓存清理</span>
</div>
</section>
</template> </template>
<template v-else-if="activeSection === 'mail'"> <template v-else-if="activeSection === 'mail'">

View File

@@ -1,5 +1,4 @@
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue' import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import AgentTraceCenterView from '../AgentTraceCenterView.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue' import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import LogDetailView from '../LogDetailView.vue' import LogDetailView from '../LogDetailView.vue'
import LogsView from '../LogsView.vue' import LogsView from '../LogsView.vue'
@@ -10,7 +9,6 @@ import { useSettings } from '../../composables/useSettings.js'
export default { export default {
name: 'SettingsView', name: 'SettingsView',
components: { components: {
AgentTraceCenterView,
HermesEmployeeSettingsPanel, HermesEmployeeSettingsPanel,
EnterpriseSelect, EnterpriseSelect,
LlmSettingsPanel, LlmSettingsPanel,

View File

@@ -4,6 +4,12 @@ import {
resolveExpenseTypeCode resolveExpenseTypeCode
} from './travelReimbursementReviewModel.js' } from './travelReimbursementReviewModel.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import {
inferPreviewKindFromFile,
isTemporaryPreviewUrl as isTemporaryDocumentPreviewUrl,
resolveDocumentPreviewAsset,
resolveDocumentPreviewKind as resolveUnifiedDocumentPreviewKind
} from '../../utils/documentPreviewAssets.js'
const SCENARIO_LABELS = { const SCENARIO_LABELS = {
expense: '报销', expense: '报销',
@@ -404,15 +410,7 @@ export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
} }
export function inferPreviewKind(file) { export function inferPreviewKind(file) {
const mediaType = String(file?.type || '').toLowerCase() return inferPreviewKindFromFile(file)
const filename = String(file?.name || '').toLowerCase()
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
return 'image'
}
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
return 'pdf'
}
return 'file'
} }
export function buildFilePreviews(files, previewRegistry) { export function buildFilePreviews(files, previewRegistry) {
@@ -449,7 +447,7 @@ export function resolveDocumentPreview(filePreviews, filename) {
} }
export function isTemporaryPreviewUrl(url) { export function isTemporaryPreviewUrl(url) {
return String(url || '').trim().toLowerCase().startsWith('blob:') return isTemporaryDocumentPreviewUrl(url)
} }
export function buildFileIdentity(file) { export function buildFileIdentity(file) {
@@ -526,45 +524,35 @@ export function filterPersistableFilePreviews(filePreviews) {
}) })
} }
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) { function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim() return resolveUnifiedDocumentPreviewKind(item)
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
} }
export function buildOcrFilePreviews(payload) { export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : [] const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents return documents
.map((item) => ({ .map((item) => {
filename: String(item?.filename || '').trim(), const asset = resolveDocumentPreviewAsset(item)
kind: resolveDocumentPreviewKind(item), return {
url: String(item?.preview_url || item?.preview_data_url || '').trim() filename: String(item?.filename || '').trim(),
})) kind: asset?.kind || '',
url: asset?.url || ''
}
})
.filter((item) => item.filename && item.kind === 'image' && item.url) .filter((item) => item.filename && item.kind === 'image' && item.url)
} }
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return documents return documents
.map((item) => ({ .map((item) => {
filename: String(item?.filename || '').trim(), const asset = resolveDocumentPreviewAsset(item)
kind: resolveDocumentPreviewKind(item), return {
url: String(item?.preview_url || item?.preview_data_url || '').trim() filename: String(item?.filename || '').trim(),
})) kind: asset?.kind || '',
url: asset?.url || ''
}
})
.filter((item) => item.filename && item.kind === 'image' && item.url) .filter((item) => item.filename && item.kind === 'image' && item.url)
} }

View File

@@ -131,7 +131,7 @@ test('attachment upload association uses conversation selection instead of legac
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/) assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
assert.match( assert.match(
submitDraftPreflightSource, submitDraftPreflightSource,
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/ /const claims = await fetchExpenseClaims\([^)]*\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
) )
assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/) assert.match(submitDraftPreflightSource, /meta: \['单据查询失败'\][\s\S]*return \{ handled: true, value: null \}/)
assert.match( assert.match(
@@ -186,6 +186,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }]) assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
}) })
test('OCR preview builders reuse receipt folder image preview endpoints', () => {
const ocrPreviews = buildOcrFilePreviews({
documents: [
{
filename: '2月23 上海-武汉.pdf',
preview_kind: 'image',
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
}
]
})
assert.deepEqual(ocrPreviews, [
{
filename: '2月23 上海-武汉.pdf',
kind: 'image',
url: '/receipt-folder/receipt-train-1/preview'
}
])
})
test('OCR receipt folder ids are kept for final draft attachment association', () => { test('OCR receipt folder ids are kept for final draft attachment association', () => {
const files = [ const files = [
{ name: 'invoice.png' }, { name: 'invoice.png' },

View File

@@ -62,6 +62,15 @@ function testReceiptFolderViewSurface() {
assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/) assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/)
} }
function testReceiptPreviewKindFollowsReturnedBlobType() {
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
assert.match(view, /import \{ inferPreviewKindFromBlob \} from '\.\.\/utils\/documentPreviewAssets\.js'/)
assert.doesNotMatch(view, /function inferPreviewKindFromBlob\(blob\)/)
assert.match(view, /const resolvedPreviewKind = inferPreviewKindFromBlob\(blob\)/)
assert.match(view, /preview_kind: resolvedPreviewKind/)
}
function testReceiptFolderServiceContract() { function testReceiptFolderServiceContract() {
const service = readProjectFile('web/src/services/receiptFolder.js') const service = readProjectFile('web/src/services/receiptFolder.js')
const ocrService = readProjectFile('web/src/services/ocr.js') const ocrService = readProjectFile('web/src/services/ocr.js')
@@ -180,6 +189,7 @@ function testAssistantUnlinkedReceiptPrompt() {
function run() { function run() {
testReceiptFolderViewSurface() testReceiptFolderViewSurface()
testReceiptPreviewKindFollowsReturnedBlobType()
testReceiptFolderServiceContract() testReceiptFolderServiceContract()
testAppShellWiresReceiptFolder() testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse() testSharedDocumentListStyleReuse()

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsService = readFileSync(new URL('../src/services/settings.js', import.meta.url), 'utf8')
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const useSettings = readFileSync(new URL('../src/composables/useSettings.js', import.meta.url), 'utf8')
test('system settings replace Agent Trace with cache management', () => {
assert.match(settingsModel, /id:\s*'cacheManagement'[\s\S]*label:\s*'缓存管理'/)
assert.doesNotMatch(settingsModel, /id:\s*'agentTraces'/)
assert.doesNotMatch(settingsScript, /AgentTraceCenterView/)
assert.match(settingsView, /activeSection === 'cacheManagement'/)
assert.match(settingsView, /一键清理缓存/)
})
test('cache management action calls the settings clear-cache endpoint', () => {
assert.match(settingsService, /export function clearSystemCaches/)
assert.match(settingsService, /\/settings\/cache\/clear/)
assert.match(useSettings, /clearSystemCaches/)
assert.match(settingsView, /@click="clearAllCaches"/)
})
test('cache management section uses an enterprise maintenance layout', () => {
assert.match(settingsView, /class="settings-card cache-management-card"/)
assert.match(settingsView, /class="card-title-with-icon"/)
assert.match(settingsView, /class="cache-management-hero-metrics"/)
assert.match(settingsView, /class="cache-scope-grid"/)
assert.match(settingsView, /OCR 识别结果/)
assert.match(settingsView, /模型失败冷却/)
assert.match(settingsView, /知识库本地索引/)
assert.match(settingsView, /不会删除源文件、业务单据和系统日志/)
assert.match(settingsView, /:class="\{ 'is-error': cacheClearFailed \}"/)
assert.match(useSettings, /const cacheClearFailed = ref\(false\)/)
assert.match(useSettings, /normalizeCacheClearErrorMessage/)
assert.doesNotMatch(settingsView, />\\s*Not Found\\s*</)
})

View File

@@ -1,17 +1,18 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8') const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8') const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const llmSettingsPanel = readFileSync(new URL('../src/views/LlmSettingsPanel.vue', import.meta.url), 'utf8')
function testLlmSectionReplacesVlmWithReranker() { function testLlmSectionReplacesVlmWithReranker() {
assert.doesNotMatch(settingsView, /VLM 模型/) assert.doesNotMatch(settingsView, /VLM 模型/)
assert.match(settingsView, /Reranker 模型配置/) assert.match(llmSettingsPanel, /Reranker 模型配置/)
assert.match(settingsScript, /rerankerProvider/) assert.match(settingsModel, /rerankerProvider/)
} }
function testRerankerCardRendersAfterEmbeddingCard() { function testRerankerCardRendersAfterEmbeddingCard() {
assert.match(settingsView, /Embedding 模型配置[\s\S]*Reranker 模型配置/) assert.match(llmSettingsPanel, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
} }
function run() { function run() {

View File

@@ -1,13 +1,13 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8') const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8') const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8') const settingsStyles = readFileSync(new URL('../src/assets/styles/views/settings-view.css', import.meta.url), 'utf8')
function testRenderingSectionUsesConciseToolbarTitle() { function testRenderingSectionUsesConciseToolbarTitle() {
assert.match(settingsScript, /title:\s*'文件渲染'/) assert.match(settingsModel, /title:\s*'文件渲染'/)
assert.doesNotMatch(settingsScript, /title:\s*'ONLYOFFICE 文件渲染配置'/) assert.doesNotMatch(settingsModel, /title:\s*'ONLYOFFICE 文件渲染配置'/)
} }
function testRenderingCardRemovesDuplicatedDescription() { function testRenderingCardRemovesDuplicatedDescription() {

View File

@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { buildSelectedFileCards } from '../src/composables/workbenchAiMode/workbenchAiComposerModel.js'
function readSource(path) { function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8') return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
} }
@@ -49,6 +51,31 @@ test('shared workbench file strip preserves OCR status badges', () => {
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/) assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
}) })
test('shared workbench file strip uses recognized image preview metadata over raw PDF type', () => {
const [card] = buildSelectedFileCards([
{
name: '2月23 上海-武汉.pdf',
type: 'application/pdf',
size: 24940,
lastModified: 1760000000000
}
], () => ({
status: 'recognized',
document: {
preview_kind: 'image',
receipt_preview_url: '/receipt-folder/receipt-train-1/preview'
}
}))
assert.equal(card.tone, 'image')
assert.equal(card.icon, 'mdi mdi-file-image-outline')
assert.deepEqual(card.previewAsset, {
kind: 'image',
url: '/receipt-folder/receipt-train-1/preview',
source: 'receipt'
})
})
test('AI mode primes attachment OCR synchronously after file selection', () => { test('AI mode primes attachment OCR synchronously after file selection', () => {
assert.match( assert.match(
filePreviewRuntime, filePreviewRuntime,