feat(web): update views and services

Views:
- AppShellRouteView.vue: update route view
- SettingsView.vue: update settings view
- TravelReimbursementCreateView.vue: update travel form view
- scripts/SettingsView.js: update settings view logic
- scripts/TravelReimbursementCreateView.js: update travel form logic

Services:
- services/orchestrator.js: update orchestrator service client
This commit is contained in:
caoxiaozhu
2026-05-12 06:40:19 +00:00
parent f6a5eeb620
commit c263fc9752
6 changed files with 561 additions and 89 deletions

View File

@@ -6,3 +6,21 @@ export function runOrchestrator(payload) {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
} }
export function fetchLatestConversation(userId) {
const params = new URLSearchParams({
user_id: String(userId || '').trim()
})
return apiRequest(`/orchestrator/conversations/latest?${params.toString()}`)
}
export function clearUserConversations(userId) {
const params = new URLSearchParams({
user_id: String(userId || '').trim()
})
return apiRequest(`/orchestrator/conversations?${params.toString()}`, {
method: 'DELETE'
})
}

View File

@@ -125,6 +125,7 @@
:key="smartEntrySessionId" :key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt" :initial-prompt="smartEntryContext.prompt"
:initial-files="smartEntryContext.files" :initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation"
:entry-source="smartEntryContext.source" :entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request" :request-context="smartEntryContext.request"
@close="closeSmartEntry" @close="closeSmartEntry"

View File

@@ -181,10 +181,71 @@
</button> </button>
</div> </div>
</section> </section>
</template> </template>
<template v-else-if="activeSection === 'llm'"> <template v-else-if="activeSection === 'session'">
<div class="model-grid"> <section class="settings-card">
<div class="card-head">
<div>
<h4>会话保留策略</h4>
<p>控制智能体会话在系统中的保留时长超过保留期的历史会话会自动清理</p>
</div>
</div>
<div class="form-grid compact-grid">
<label class="field">
<span><em>*</em> 保留会话天数</span>
<div
ref="sessionRetentionPickerRef"
class="session-picker-filter"
:class="{ open: sessionRetentionPickerOpen }"
>
<button
class="session-picker-trigger"
type="button"
:aria-expanded="sessionRetentionPickerOpen"
aria-haspopup="dialog"
@click="toggleSessionRetentionPicker"
>
<span class="session-picker-label">{{ pageState.sessionForm.conversationRetentionDays }} </span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="sessionRetentionPickerOpen"
class="session-picker-popover"
role="dialog"
aria-label="选择会话保留天数"
>
<header>
<strong>选择会话保留天数</strong>
<button type="button" aria-label="关闭会话保留天数选择" @click="closeSessionRetentionPicker">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="session-picker-option-list">
<button
v-for="option in sessionRetentionOptions"
:key="option.value"
type="button"
class="session-picker-option"
:class="{ active: pageState.sessionForm.conversationRetentionDays === option.value }"
@click="selectSessionRetentionDays(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<small>最小 1 最大 10 按会话最后活跃时间计算</small>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'llm'">
<div class="model-grid">
<section class="settings-card"> <section class="settings-card">
<div class="card-head"> <div class="card-head">
<div> <div>

View File

@@ -90,6 +90,35 @@
</div> </div>
</div> </div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block">
<strong>系统识别</strong>
<p class="review-summary">{{ message.reviewPayload.intent_summary }}</p>
<div v-if="message.reviewPayload.slot_cards?.length" class="review-mini-grid">
<article
v-for="item in message.reviewPayload.slot_cards.slice(0, 4)"
:key="`${message.id}-${item.key}`"
class="review-slot-card compact"
:class="item.status"
>
<span>{{ item.label }}</span>
<strong>{{ item.value || '待补充' }}</strong>
</article>
</div>
<div v-if="message.reviewPayload.confirmation_actions?.length" class="action-list compact">
<article
v-for="item in message.reviewPayload.confirmation_actions"
:key="`${message.id}-${item.action_type}-${item.label}`"
class="action-card"
:class="item.emphasis"
>
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.description }}</p>
</div>
</article>
</div>
</div>
<div v-if="message.role === 'assistant' && message.draftPayload" class="draft-preview"> <div v-if="message.role === 'assistant' && message.draftPayload" class="draft-preview">
<header> <header>
<strong>{{ message.draftPayload.title }}</strong> <strong>{{ message.draftPayload.title }}</strong>
@@ -193,6 +222,19 @@
</div> </div>
</section> </section>
<section v-if="currentInsight.agent.reviewPayload" class="insight-card">
<div class="card-head">
<h4>识别结果</h4>
</div>
<div class="note-block">
<span>{{ currentInsight.agent.reviewPayload.intent }}</span>
<strong>{{ currentInsight.agent.reviewPayload.intent_summary }}</strong>
<p v-if="currentInsight.agent.reviewPayload.missing_slots?.length">
当前仍缺少{{ currentInsight.agent.reviewPayload.missing_slots.join('') }}
</p>
</div>
</section>
<section class="insight-card"> <section class="insight-card">
<div class="card-head"> <div class="card-head">
<h4>运行明细</h4> <h4>运行明细</h4>
@@ -217,6 +259,132 @@
</div> </div>
</section> </section>
<section v-if="currentInsight.agent.reviewPayload?.risk_briefs?.length" class="insight-card">
<div class="card-head">
<h4>历史风险与注意事项</h4>
</div>
<div class="review-brief-list">
<article
v-for="item in currentInsight.agent.reviewPayload.risk_briefs"
:key="`${item.title}-${item.level}`"
class="review-brief-card"
:class="item.level"
>
<strong>{{ item.title }}</strong>
<p>{{ item.content }}</p>
</article>
</div>
</section>
<section v-if="currentInsight.agent.reviewPayload?.slot_cards?.length" class="insight-card">
<div class="card-head">
<h4>待确认字段</h4>
</div>
<div class="review-slot-grid">
<article
v-for="item in currentInsight.agent.reviewPayload.slot_cards"
:key="item.key"
class="review-slot-card"
:class="item.status"
>
<header>
<span>{{ item.label }}</span>
<small>{{ item.source }}</small>
</header>
<strong>{{ item.value || '待补充' }}</strong>
<p v-if="item.hint">{{ item.hint }}</p>
</article>
</div>
</section>
<section v-if="currentInsight.agent.reviewPayload?.claim_groups?.length" class="insight-card">
<div class="card-head">
<h4>分单建议</h4>
</div>
<div class="review-claim-list">
<article
v-for="item in currentInsight.agent.reviewPayload.claim_groups"
:key="item.group_code"
class="review-claim-card"
>
<header>
<strong>{{ item.title }}</strong>
<span>{{ item.scene_label }}</span>
</header>
<p>{{ item.rationale }}</p>
<div class="message-detail-chip-row">
<span class="message-action-chip">票据 {{ item.document_indexes.join('、') || '待补' }}</span>
<span class="message-action-chip">金额 {{ item.amount_total.toFixed(2) }} </span>
</div>
</article>
</div>
</section>
<section v-if="currentInsight.agent.reviewPayload?.document_cards?.length" class="insight-card">
<div class="card-head">
<h4>票据识别结果</h4>
</div>
<div class="review-document-list">
<article
v-for="item in currentInsight.agent.reviewPayload.document_cards"
:key="`${item.index}-${item.filename}`"
class="review-document-card"
>
<header>
<div>
<strong>票据 {{ item.index }}</strong>
<span>{{ item.filename }}</span>
</div>
<span class="message-action-chip">{{ item.scene_label }}</span>
</header>
<div class="document-preview" :class="resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.kind || 'file'">
<img
v-if="resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.kind === 'image'"
:src="resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.url"
:alt="item.filename"
/>
<div v-else class="document-preview-placeholder">
<i class="mdi mdi-file-document-outline"></i>
<span>{{ resolveDocumentPreview(currentInsight.agent.filePreviews, item.filename)?.kind === 'pdf' ? 'PDF' : '附件' }}</span>
</div>
</div>
<p>{{ item.summary || '暂无摘要。' }}</p>
<div v-if="item.fields?.length" class="review-doc-field-grid">
<article v-for="field in item.fields" :key="`${item.filename}-${field.label}`" class="review-doc-field-card">
<span>{{ field.label }}</span>
<strong>{{ field.value }}</strong>
</article>
</div>
<div v-if="item.warnings?.length" class="message-detail-chip-row">
<span v-for="warning in item.warnings" :key="warning" class="message-risk-chip">{{ warning }}</span>
</div>
</article>
</div>
</section>
<section v-if="currentInsight.agent.reviewPayload?.confirmation_actions?.length" class="insight-card">
<div class="card-head">
<h4>确认动作</h4>
</div>
<div class="action-list">
<article
v-for="item in currentInsight.agent.reviewPayload.confirmation_actions"
:key="`${item.action_type}-${item.label}`"
class="action-card"
:class="item.emphasis"
>
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.description || item.action_type }}</p>
</div>
</article>
</div>
</section>
<section v-if="currentInsight.agent.selectedCapabilityCodes?.length" class="insight-card"> <section v-if="currentInsight.agent.selectedCapabilityCodes?.length" class="insight-card">
<div class="card-head"> <div class="card-head">
<h4>命中能力</h4> <h4>命中能力</h4>

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { fetchSettings, saveSettings, testModelConnectivity } from '../../services/settings.js' import { fetchSettings, saveSettings, testModelConnectivity } from '../../services/settings.js'
@@ -19,14 +19,22 @@ const SECTION_DEFINITIONS = [
longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。', longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。',
actionLabel: '保存企业信息' actionLabel: '保存企业信息'
}, },
{ {
id: 'admin', id: 'admin',
label: '管理员安全', label: '管理员安全',
title: '管理员账号与安全策略', title: '管理员账号与安全策略',
desc: '账号、密码与登录安全', desc: '账号、密码与登录安全',
longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。', longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。',
actionLabel: '保存安全设置' actionLabel: '保存安全设置'
}, },
{
id: 'session',
label: '会话设置',
title: '会话留存设置',
desc: '会话保留天数',
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
actionLabel: '保存会话设置'
},
{ {
id: 'llm', id: 'llm',
label: '大语言模型', label: '大语言模型',
@@ -127,7 +135,11 @@ const MODEL_TEST_CONFIGS = {
} }
} }
const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS) const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
value: index + 1,
label: `${index + 1}`
}))
function normalizeValue(value) { function normalizeValue(value) {
return String(value ?? '').trim() return String(value ?? '').trim()
@@ -168,18 +180,21 @@ function buildDefaultState(companyProfile, currentUser) {
recordNumber: '', recordNumber: '',
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.` copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
}, },
adminForm: { adminForm: {
adminAccount, adminAccount,
adminEmail, adminEmail,
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30), sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
noticeEmail: adminEmail, noticeEmail: adminEmail,
mfaEnabled: true, mfaEnabled: true,
strongPassword: true, strongPassword: true,
loginAlertEnabled: true, loginAlertEnabled: true,
adminPasswordConfigured: false adminPasswordConfigured: false
}, },
sessionForm: {
conversationRetentionDays: 3
},
llmForm: { llmForm: {
mainProvider: 'Codex', mainProvider: 'Codex',
mainModel: 'codex-mini-latest', mainModel: 'codex-mini-latest',
@@ -266,6 +281,7 @@ function mergeState(baseState, overrideState) {
return { return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) }, companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) }, adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
llmForm: mergedLlmForm, llmForm: mergedLlmForm,
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) }, renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) }, logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
@@ -276,11 +292,12 @@ function mergeState(baseState, overrideState) {
function sanitizeForStorage(state) { function sanitizeForStorage(state) {
return { return {
companyForm: { ...state.companyForm }, companyForm: { ...state.companyForm },
adminForm: { adminForm: {
...state.adminForm, ...state.adminForm,
newPassword: '', newPassword: '',
confirmPassword: '' confirmPassword: ''
}, },
sessionForm: { ...state.sessionForm },
llmForm: { llmForm: {
...state.llmForm, ...state.llmForm,
mainApiKey: '', mainApiKey: '',
@@ -373,11 +390,15 @@ function computeSectionStatus(state) {
normalizeValue(state.companyForm.displayName) && normalizeValue(state.companyForm.displayName) &&
normalizeValue(state.companyForm.copyright) normalizeValue(state.companyForm.copyright)
), ),
admin: Boolean( admin: Boolean(
normalizeValue(state.adminForm.adminAccount) && normalizeValue(state.adminForm.adminAccount) &&
normalizeValue(state.adminForm.adminEmail) && normalizeValue(state.adminForm.adminEmail) &&
Number(state.adminForm.sessionTimeout) >= 5 Number(state.adminForm.sessionTimeout) >= 5
), ),
session: Boolean(
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
Number(state.sessionForm.conversationRetentionDays) <= 10
),
llm: Boolean( llm: Boolean(
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) && isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) && isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
@@ -414,9 +435,11 @@ export default {
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState() const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value) const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings())) const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
const activeSection = ref('profile') const activeSection = ref('profile')
const modelTestState = ref({ const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null)
const modelTestState = ref({
main: { status: 'idle', message: '' }, main: { status: 'idle', message: '' },
backup: { status: 'idle', message: '' }, backup: { status: 'idle', message: '' },
vlm: { status: 'idle', message: '' }, vlm: { status: 'idle', message: '' },
@@ -424,8 +447,9 @@ export default {
}) })
const sections = SECTION_DEFINITIONS const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS const logLevels = LOG_LEVELS
const providerOptions = PROVIDER_OPTIONS const providerOptions = PROVIDER_OPTIONS
const sessionRetentionOptions = SESSION_RETENTION_OPTIONS
const sectionStatus = computed(() => computeSectionStatus(pageState.value)) const sectionStatus = computed(() => computeSectionStatus(pageState.value))
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length) const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
@@ -497,6 +521,7 @@ export default {
return { return {
companyForm: { ...pageState.value.companyForm }, companyForm: { ...pageState.value.companyForm },
adminForm: { ...pageState.value.adminForm }, adminForm: { ...pageState.value.adminForm },
sessionForm: { ...pageState.value.sessionForm },
llmForm: buildLlmPayload(pageState.value.llmForm), llmForm: buildLlmPayload(pageState.value.llmForm),
renderForm: buildRenderPayload(pageState.value.renderForm), renderForm: buildRenderPayload(pageState.value.renderForm),
logForm: { ...pageState.value.logForm }, logForm: { ...pageState.value.logForm },
@@ -504,7 +529,7 @@ export default {
} }
} }
async function persistRemoteSettings(successMessage, options = {}) { async function persistRemoteSettings(successMessage, options = {}) {
try { try {
const snapshot = await saveSettings(buildSettingsPayload()) const snapshot = await saveSettings(buildSettingsPayload())
applyLoadedSnapshot(snapshot, options) applyLoadedSnapshot(snapshot, options)
@@ -516,13 +541,40 @@ export default {
} }
} }
function activateSection(sectionId) { function activateSection(sectionId) {
activeSection.value = sectionId sessionRetentionPickerOpen.value = false
} activeSection.value = sectionId
}
function toggleBoolean(formKey, field) { function toggleBoolean(formKey, field) {
pageState.value[formKey][field] = !pageState.value[formKey][field] pageState.value[formKey][field] = !pageState.value[formKey][field]
} }
function toggleSessionRetentionPicker() {
sessionRetentionPickerOpen.value = !sessionRetentionPickerOpen.value
}
function closeSessionRetentionPicker() {
sessionRetentionPickerOpen.value = false
}
function selectSessionRetentionDays(value) {
pageState.value.sessionForm.conversationRetentionDays = Number(value)
closeSessionRetentionPicker()
}
function handleDocumentPointerDown(event) {
if (!sessionRetentionPickerOpen.value) {
return
}
const target = event.target
if (sessionRetentionPickerRef.value?.contains(target)) {
return
}
closeSessionRetentionPicker()
}
function applyProviderPreset(testKey) { function applyProviderPreset(testKey) {
const config = MODEL_TEST_CONFIGS[testKey] const config = MODEL_TEST_CONFIGS[testKey]
@@ -626,8 +678,8 @@ export default {
}) })
} }
async function saveAdminSection() { async function saveAdminSection() {
const adminForm = pageState.value.adminForm const adminForm = pageState.value.adminForm
if (!normalizeValue(adminForm.adminAccount)) { if (!normalizeValue(adminForm.adminAccount)) {
toast('请输入管理员账号。') toast('请输入管理员账号。')
@@ -662,7 +714,24 @@ export default {
preserveRenderSecret: true, preserveRenderSecret: true,
preserveMailPassword: true preserveMailPassword: true
}) })
} }
async function saveSessionSection() {
const sessionForm = pageState.value.sessionForm
const retentionDays = Number(sessionForm.conversationRetentionDays)
if (retentionDays < 1 || retentionDays > 10) {
toast('会话保留天数必须在 1 到 10 天之间。')
return
}
await persistRemoteSettings('会话设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveLlmSection() { async function saveLlmSection() {
const llmForm = pageState.value.llmForm const llmForm = pageState.value.llmForm
@@ -757,15 +826,20 @@ export default {
return return
} }
if (activeSection.value === 'admin') { if (activeSection.value === 'admin') {
await saveAdminSection() await saveAdminSection()
return return
} }
if (activeSection.value === 'llm') { if (activeSection.value === 'session') {
await saveLlmSection() await saveSessionSection()
return return
} }
if (activeSection.value === 'llm') {
await saveLlmSection()
return
}
if (activeSection.value === 'logs') { if (activeSection.value === 'logs') {
await saveLogsSection() await saveLogsSection()
@@ -780,14 +854,23 @@ export default {
await saveMailSection() await saveMailSection()
} }
onMounted(() => { onMounted(() => {
loadSettingsSnapshot() if (typeof document !== 'undefined') {
}) document.addEventListener('pointerdown', handleDocumentPointerDown)
}
return { loadSettingsSnapshot()
activeSection, })
activeSectionConfig,
activateSection, onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
}
})
return {
activeSection,
activeSectionConfig,
activateSection,
applyProviderPreset, applyProviderPreset,
clearRenderSecretMask, clearRenderSecretMask,
clearModelSecretMask, clearModelSecretMask,
@@ -795,14 +878,20 @@ export default {
getModelTestState, getModelTestState,
isModelTesting, isModelTesting,
logLevels, logLevels,
modelTestState, modelTestState,
pageState, pageState,
providerOptions, providerOptions,
saveActiveSection, sessionRetentionOptions,
sectionStatus, sessionRetentionPickerOpen,
sections, sessionRetentionPickerRef,
testModelConnection, saveActiveSection,
toggleBoolean sectionStatus,
} sections,
} selectSessionRetentionDays,
} testModelConnection,
toggleSessionRetentionPicker,
closeSessionRetentionPicker,
toggleBoolean
}
}
}

View File

@@ -1,4 +1,4 @@
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { recognizeOcrFiles } from '../../services/ocr.js' import { recognizeOcrFiles } from '../../services/ocr.js'
@@ -63,11 +63,29 @@ function createMessage(role, text, attachments = [], extras = {}) {
citations: [], citations: [],
suggestedActions: [], suggestedActions: [],
draftPayload: null, draftPayload: null,
reviewPayload: null,
riskFlags: [], riskFlags: [],
...extras ...extras
} }
} }
function formatMessageTime(value) {
if (!value) {
return nowTime()
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return nowTime()
}
return parsed.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
function sanitizeRequest(request) { function sanitizeRequest(request) {
if (!request) return { ...DEFAULT_REQUEST } if (!request) return { ...DEFAULT_REQUEST }
return { return {
@@ -129,6 +147,22 @@ function buildMessageMeta(payload, fileNames = []) {
return items return items
} }
function buildStoredMessageMeta(messageJson, attachmentNames = []) {
const payload = messageJson?.orchestrator_payload
if (payload) {
return buildMessageMeta(payload, attachmentNames)
}
const items = []
if (messageJson?.status) {
items.push(`状态: ${messageJson.status}`)
}
if (attachmentNames.length) {
items.push(`附件: ${attachmentNames.length}`)
}
return items
}
function normalizeOcrDocuments(payload) { function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : [] const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.slice(0, 5).map((item) => ({ return documents.slice(0, 5).map((item) => ({
@@ -149,6 +183,43 @@ function buildOcrSummary(payload) {
return parts.join('') return parts.join('')
} }
function inferPreviewKind(file) {
const mediaType = String(file?.type || '').toLowerCase()
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'
}
function buildFilePreviews(files, previewRegistry) {
return files.map((file) => {
const kind = inferPreviewKind(file)
if (kind !== 'image') {
return {
filename: file.name,
kind
}
}
const url = URL.createObjectURL(file)
previewRegistry.push(url)
return {
filename: file.name,
kind,
url
}
})
}
function resolveDocumentPreview(filePreviews, filename) {
if (!Array.isArray(filePreviews)) return null
return filePreviews.find((item) => item.filename === filename) ?? null
}
function buildWelcomeInsight(entrySource, linkedRequest) { function buildWelcomeInsight(entrySource, linkedRequest) {
return { return {
intent: 'welcome', intent: 'welcome',
@@ -163,6 +234,41 @@ function buildWelcomeInsight(entrySource, linkedRequest) {
} }
} }
function resolveInitialConversationId(conversation) {
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
}
function resolveInitialDraftClaimId(conversation) {
return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim()
}
function normalizeInitialConversationMessages(conversation) {
const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : []
return rawMessages.map((item) => {
const messageJson = item?.message_json || item?.messageJson || {}
const attachmentNames = Array.isArray(messageJson?.attachment_names)
? messageJson.attachment_names.filter(Boolean)
: []
const orchestratorPayload = messageJson?.orchestrator_payload || null
const result = orchestratorPayload?.result || {}
return createMessage(item.role, item.content, attachmentNames, {
id: `restored-${item.id || ++messageSeed}`,
time: formatMessageTime(item.created_at || item.createdAt),
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
suggestedActions:
item.role === 'assistant' && Array.isArray(result?.suggested_actions)
? result.suggested_actions
: [],
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
})
})
}
function buildErrorInsight(error, fileNames = []) { function buildErrorInsight(error, fileNames = []) {
return { return {
intent: 'agent', intent: 'agent',
@@ -183,17 +289,19 @@ function buildErrorInsight(error, fileNames = []) {
citations: [], citations: [],
suggestedActions: [], suggestedActions: [],
draftPayload: null, draftPayload: null,
reviewPayload: null,
riskFlags: [], riskFlags: [],
toolCount: 0, toolCount: 0,
failedToolCount: 0, failedToolCount: 0,
selectedCapabilityCodes: [], selectedCapabilityCodes: [],
filePreviews: [],
statusLabel: '失败', statusLabel: '失败',
statusTone: 'note' statusTone: 'note'
} }
} }
} }
function buildAgentInsight(payload, fileNames = []) { function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
const trace = payload?.trace_summary || {} const trace = payload?.trace_summary || {}
const result = payload?.result || {} const result = payload?.result || {}
const statusLabel = resolveStatusLabel(payload?.status) const statusLabel = resolveStatusLabel(payload?.status)
@@ -219,12 +327,14 @@ function buildAgentInsight(payload, fileNames = []) {
citations: Array.isArray(result?.citations) ? result.citations : [], citations: Array.isArray(result?.citations) ? result.citations : [],
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
draftPayload: result?.draft_payload || null, draftPayload: result?.draft_payload || null,
reviewPayload: result?.review_payload || null,
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
toolCount: Number(trace?.tool_count || 0), toolCount: Number(trace?.tool_count || 0),
failedToolCount: Number(trace?.failed_tool_count || 0), failedToolCount: Number(trace?.failed_tool_count || 0),
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
? trace.selected_capability_codes ? trace.selected_capability_codes
: [], : [],
filePreviews,
statusLabel, statusLabel,
statusTone: resolveStatusTone(payload?.status) statusTone: resolveStatusTone(payload?.status)
} }
@@ -242,6 +352,10 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
initialConversation: {
type: Object,
default: null
},
entrySource: { entrySource: {
type: String, type: String,
default: 'requests' default: 'requests'
@@ -260,8 +374,23 @@ export default {
const composerDraft = ref('') const composerDraft = ref('')
const attachedFiles = ref([]) const attachedFiles = ref([])
const submitting = ref(false) const submitting = ref(false)
const messages = ref([])
const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const restoredMessages = normalizeInitialConversationMessages(props.initialConversation)
const messages = ref(
restoredMessages.length
? restoredMessages
: [
createMessage(
'assistant',
props.entrySource === 'detail'
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你的提问会直接进入真实智能体链路。`
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
)
]
)
const conversationId = ref(resolveInitialConversationId(props.initialConversation))
const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation))
const previewRegistry = []
const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value)) const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value))
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
@@ -333,15 +462,6 @@ export default {
] ]
}) })
messages.value = [
createMessage(
'assistant',
props.entrySource === 'detail'
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你的提问会直接进入真实智能体链路。`
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
)
]
onMounted(() => { onMounted(() => {
currentInsight.value = buildWelcomeInsight(props.entrySource, linkedRequest.value) currentInsight.value = buildWelcomeInsight(props.entrySource, linkedRequest.value)
if (props.initialPrompt?.trim() || props.initialFiles.length) { if (props.initialPrompt?.trim() || props.initialFiles.length) {
@@ -353,6 +473,12 @@ export default {
} }
}) })
onBeforeUnmount(() => {
for (const url of previewRegistry) {
URL.revokeObjectURL(url)
}
})
function scrollToBottom() { function scrollToBottom() {
if (!messageListRef.value) return if (!messageListRef.value) return
messageListRef.value.scrollTop = messageListRef.value.scrollHeight messageListRef.value.scrollTop = messageListRef.value.scrollHeight
@@ -412,6 +538,7 @@ export default {
const rawText = composerDraft.value.trim() const rawText = composerDraft.value.trim()
const files = Array.from(attachedFiles.value) const files = Array.from(attachedFiles.value)
const fileNames = files.map((file) => file.name) const fileNames = files.map((file) => file.name)
const filePreviews = buildFilePreviews(files, previewRegistry)
const userText = const userText =
rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。` rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
@@ -451,6 +578,7 @@ export default {
const payload = await runOrchestrator({ const payload = await runOrchestrator({
source: 'user_message', source: 'user_message',
user_id: user.username || user.name || 'anonymous', user_id: user.username || user.name || 'anonymous',
conversation_id: conversationId.value || null,
message: backendMessage, message: backendMessage,
context_json: { context_json: {
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [], role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
@@ -461,11 +589,16 @@ export default {
request_context: linkedRequest.value, request_context: linkedRequest.value,
attachment_names: fileNames, attachment_names: fileNames,
attachment_count: fileNames.length, attachment_count: fileNames.length,
draft_claim_id: draftClaimId.value || undefined,
ocr_summary: ocrSummary, ocr_summary: ocrSummary,
ocr_documents: ocrDocuments ocr_documents: ocrDocuments
} }
}) })
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
draftClaimId.value =
String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
replaceMessage( replaceMessage(
pendingMessage.id, pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
@@ -475,10 +608,11 @@ export default {
? payload.result.suggested_actions ? payload.result.suggested_actions
: [], : [],
draftPayload: payload?.result?.draft_payload || null, draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [] riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
}) })
) )
currentInsight.value = buildAgentInsight(payload, fileNames) currentInsight.value = buildAgentInsight(payload, fileNames, filePreviews)
} catch (error) { } catch (error) {
replaceMessage( replaceMessage(
pendingMessage.id, pendingMessage.id,
@@ -514,6 +648,7 @@ export default {
composerPlaceholder, composerPlaceholder,
currentIntentLabel, currentIntentLabel,
shortcuts, shortcuts,
resolveDocumentPreview,
triggerFileUpload, triggerFileUpload,
handleFilesChange, handleFilesChange,
runShortcut, runShortcut,