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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
118
web/src/utils/documentPreviewAssets.js
Normal file
118
web/src/utils/documentPreviewAssets.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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],
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
@@ -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'">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
39
web/tests/settings-cache-management-section.test.mjs
Normal file
39
web/tests/settings-cache-management-section.test.mjs
Normal 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*</)
|
||||||
|
})
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user