feat: 集成Hermes智能体系统,增强聊天和差旅报销功能
This commit is contained in:
@@ -37,10 +37,26 @@
|
||||
.answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; }
|
||||
.answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; }
|
||||
.agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; }
|
||||
.agent-answer-content { max-width: 760px; display: grid; gap: 10px; }
|
||||
.agent-answer-table-wrap { overflow-x: auto; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
|
||||
.agent-answer-table { width: 100%; min-width: 360px; border-collapse: collapse; font-size: 13px; }
|
||||
.agent-answer-table th, .agent-answer-table td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; text-align: left; }
|
||||
.agent-answer-table th { background: #eef7f2; color: #0f172a; font-weight: 800; }
|
||||
.agent-answer-table td { color: #334155; font-weight: 650; }
|
||||
.agent-answer-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; }
|
||||
.agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: #eef7f2; color: #0f766e; font-size: 12px; font-weight: 760; }
|
||||
.agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; }
|
||||
.agent-detail-block > strong { color: #0f172a; font-size: 12px; font-weight: 820; }
|
||||
.agent-citation-disclosure { overflow: hidden; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; }
|
||||
.agent-citation-disclosure summary { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: #0f172a; cursor: pointer; list-style: none; }
|
||||
.agent-citation-disclosure summary::-webkit-details-marker { display: none; }
|
||||
.agent-citation-disclosure summary strong { font-size: 12px; font-weight: 820; }
|
||||
.agent-citation-disclosure summary span { color: #64748b; font-size: 12px; font-weight: 720; }
|
||||
.agent-citation-disclosure summary i { margin-left: auto; color: #64748b; transition: transform .18s ease; }
|
||||
.agent-citation-disclosure[open] summary { border-bottom: 1px solid #eef2f7; }
|
||||
.agent-citation-disclosure[open] summary i { transform: rotate(180deg); }
|
||||
.agent-citation-disclosure .agent-citation-list { padding: 10px; }
|
||||
.agent-detail-chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.agent-risk-chip, .agent-action-chip { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; font-size: 12px; font-weight: 760; }
|
||||
.agent-risk-chip { background: #fff1f2; color: #be123c; }
|
||||
|
||||
@@ -379,6 +379,52 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-answer-content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-answer-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-answer-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.message-answer-table {
|
||||
width: 100%;
|
||||
min-width: 360px;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-answer-table th,
|
||||
.message-answer-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message-answer-table th {
|
||||
background: #eff6ff;
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.message-answer-table td {
|
||||
color: #334155;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.message-answer-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.message-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -429,6 +475,58 @@
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.message-citation-disclosure {
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 16px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.message-citation-disclosure summary {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.message-citation-disclosure summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-citation-disclosure summary strong {
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.message-citation-disclosure summary span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.message-citation-disclosure summary i {
|
||||
margin-left: auto;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.message-citation-disclosure[open] summary {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.message-citation-disclosure[open] summary i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.message-citation-disclosure .message-citation-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.expense-query-block {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,8 @@ export function useChat(activeView) {
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
active_case_id: activeCase.value?.id || ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -86,11 +86,13 @@ function readStoredUsername() {
|
||||
}
|
||||
|
||||
function buildAnonymousUser() {
|
||||
return {
|
||||
username: '',
|
||||
name: '',
|
||||
role: '',
|
||||
roleCodes: [],
|
||||
return {
|
||||
username: '',
|
||||
name: '',
|
||||
role: '',
|
||||
position: '',
|
||||
grade: '',
|
||||
roleCodes: [],
|
||||
email: '',
|
||||
avatar: '',
|
||||
isAdmin: false
|
||||
@@ -101,11 +103,13 @@ function buildLegacyAdminUser(username = '') {
|
||||
const normalized = String(username || '').trim()
|
||||
const name = normalized || DEFAULT_USER_NAME
|
||||
|
||||
return {
|
||||
username: normalized,
|
||||
name,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
roleCodes: ['manager'],
|
||||
return {
|
||||
username: normalized,
|
||||
name,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
position: DEFAULT_USER_ROLE,
|
||||
grade: '',
|
||||
roleCodes: ['manager'],
|
||||
email: '',
|
||||
avatar: name.slice(0, 1).toUpperCase(),
|
||||
isAdmin: true
|
||||
@@ -127,11 +131,13 @@ function readStoredUser() {
|
||||
const name = String(payload.name || username || DEFAULT_USER_NAME).trim()
|
||||
const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||
|
||||
return {
|
||||
username,
|
||||
name,
|
||||
role: String(payload.role || DEFAULT_USER_ROLE),
|
||||
roleCodes,
|
||||
return {
|
||||
username,
|
||||
name,
|
||||
role: String(payload.role || DEFAULT_USER_ROLE),
|
||||
position: String(payload.position || ''),
|
||||
grade: String(payload.grade || ''),
|
||||
roleCodes,
|
||||
email: String(payload.email || ''),
|
||||
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
|
||||
isAdmin: Boolean(payload.isAdmin)
|
||||
|
||||
@@ -1,322 +1,333 @@
|
||||
<template>
|
||||
<section class="qa-view">
|
||||
<div class="qa-layout">
|
||||
<aside class="left-column">
|
||||
<article class="panel side-panel conversation-list">
|
||||
<header>
|
||||
<h3>问答会话</h3>
|
||||
<button class="outline-action" type="button" @click="emit('draft', '')">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新建会话</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="session-scroll">
|
||||
<button
|
||||
v-for="item in sessions"
|
||||
:key="item.title"
|
||||
class="session-row"
|
||||
:class="{ active: item.active }"
|
||||
type="button"
|
||||
@click="applyPrompt(item.title)"
|
||||
>
|
||||
<span><i class="mdi mdi-message-processing-outline"></i></span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<time>{{ item.time }}</time>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<article class="panel chat-panel">
|
||||
<div ref="localMessageList" class="message-stream" aria-live="polite">
|
||||
<div class="talk-row user">
|
||||
<span class="avatar user-avatar">张</span>
|
||||
<div class="talk-content">
|
||||
<header><strong>张明</strong><time>10:32</time></header>
|
||||
<p class="user-question">北京出差,酒店超标报销怎么处理?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="talk-row assistant">
|
||||
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
||||
<div class="talk-content">
|
||||
<header><strong>财务AI助手</strong><time>10:32</time></header>
|
||||
<div class="answer-card">
|
||||
<section>
|
||||
<h4>结论</h4>
|
||||
<p>酒店费用超过标准的部分原则上不予报销,特殊情况可申请例外报销。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>处理建议</h4>
|
||||
<ul>
|
||||
<li>超标部分由个人自理或按制度退回,保留超标说明和相关凭证。</li>
|
||||
<li>符合公司相关政策的,可提交佐证材料,申请例外报销。</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>适用规则</h4>
|
||||
<ul>
|
||||
<li>《差旅报销管理办法(2024版)》第十二条:住宿标准及超标处理</li>
|
||||
<li>《费用报销审批流程》附件1:国内差旅住宿标准</li>
|
||||
</ul>
|
||||
</section>
|
||||
<footer>
|
||||
<span>是否有帮助?</span>
|
||||
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
|
||||
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="talk-row user">
|
||||
<span class="avatar user-avatar">张</span>
|
||||
<div class="talk-content">
|
||||
<header><strong>张明</strong><time>10:35</time></header>
|
||||
<p class="user-question">如果出差地公司名称不一致还能报销吗?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="talk-row assistant">
|
||||
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
||||
<div class="talk-content">
|
||||
<header><strong>财务AI助手</strong><time>10:35</time></header>
|
||||
<div class="answer-card compact">
|
||||
<section>
|
||||
<h4>结论</h4>
|
||||
<p>一般情况下,差旅地与参会公司名称不一致需按异常处理,建议提供情况说明并加盖公章或补充邀请材料。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>适用规则</h4>
|
||||
<ul>
|
||||
<li>《发票管理规定及失误销细则》第二章:发票基本要求</li>
|
||||
<li>《差旅报销管理办法》附件1:报销凭证要求</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
|
||||
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
|
||||
<template v-if="message.role === 'user'">我</template>
|
||||
<i v-else class="mdi mdi-robot-outline"></i>
|
||||
</span>
|
||||
<div class="talk-content">
|
||||
<header>
|
||||
<strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong>
|
||||
<time>刚刚</time>
|
||||
</header>
|
||||
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p>
|
||||
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
|
||||
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
||||
<strong>风险标签</strong>
|
||||
<div class="agent-detail-chip-row">
|
||||
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block">
|
||||
<strong>引用依据</strong>
|
||||
<div class="agent-citation-list">
|
||||
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card">
|
||||
<header>
|
||||
<span>{{ item.title }}</span>
|
||||
<small>{{ item.version || item.source_type }}</small>
|
||||
</header>
|
||||
<p>{{ item.excerpt || item.code }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.suggestedActions?.length" class="agent-detail-block">
|
||||
<strong>建议动作</strong>
|
||||
<div class="agent-detail-chip-row">
|
||||
<span
|
||||
v-for="item in message.suggestedActions"
|
||||
:key="`${message.id}-${item.action_type}-${item.label}`"
|
||||
class="agent-action-chip"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card">
|
||||
<header>
|
||||
<strong>{{ message.draftPayload.title }}</strong>
|
||||
<span>待人工确认</span>
|
||||
</header>
|
||||
<pre>{{ message.draftPayload.body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="prompt-toolbar">
|
||||
<span>猜你想问</span>
|
||||
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
|
||||
<i :class="prompt.icon"></i>
|
||||
{{ prompt.short }}
|
||||
</button>
|
||||
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<textarea
|
||||
:value="draft"
|
||||
rows="2"
|
||||
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
|
||||
:disabled="sending"
|
||||
@input="emit('draft', $event.target.value)"
|
||||
@keydown.ctrl.enter.prevent="emit('send')"
|
||||
></textarea>
|
||||
<button
|
||||
class="send-button"
|
||||
type="button"
|
||||
aria-label="发送问题"
|
||||
:disabled="sending || !String(draft || '').trim()"
|
||||
@click="emit('send')"
|
||||
>
|
||||
<i :class="sending ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="right-column">
|
||||
<article class="panel info-panel semantic-debug-panel">
|
||||
<header>
|
||||
<h3><i class="mdi mdi-shape-outline"></i> 语义解析调试</h3>
|
||||
<button type="button" @click="useDraftAsSemanticInput">带入输入框</button>
|
||||
</header>
|
||||
|
||||
<div class="semantic-debug-body">
|
||||
<label class="semantic-debug-input">
|
||||
<span>自然语言问题</span>
|
||||
<textarea
|
||||
v-model="semanticDraft"
|
||||
rows="4"
|
||||
placeholder="例如:查一下本周报销超标风险"
|
||||
@keydown.ctrl.enter.prevent="parseSemanticQuery"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="semantic-debug-actions">
|
||||
<button
|
||||
v-for="item in semanticExamples"
|
||||
:key="item"
|
||||
class="semantic-chip"
|
||||
type="button"
|
||||
@click="applySemanticExample(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="semantic-debug-toolbar">
|
||||
<button class="semantic-parse-btn" type="button" :disabled="semanticLoading" @click="parseSemanticQuery">
|
||||
<i :class="semanticLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-play-circle-outline'"></i>
|
||||
<span>{{ semanticLoading ? '解析中...' : '开始解析' }}</span>
|
||||
</button>
|
||||
<span class="semantic-inline-meta">
|
||||
<template v-if="semanticResult">run_id:{{ semanticResult.run_id }}</template>
|
||||
<template v-else>支持 Ctrl + Enter</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="semanticError" class="semantic-debug-error">{{ semanticError }}</p>
|
||||
|
||||
<div v-if="semanticResult" class="semantic-result-stack">
|
||||
<div class="semantic-result-grid">
|
||||
<article class="semantic-result-card">
|
||||
<span>场景</span>
|
||||
<strong>{{ semanticResult.scenario }}</strong>
|
||||
</article>
|
||||
<article class="semantic-result-card">
|
||||
<span>意图</span>
|
||||
<strong>{{ semanticResult.intent }}</strong>
|
||||
</article>
|
||||
<article class="semantic-result-card">
|
||||
<span>权限</span>
|
||||
<strong>{{ semanticResult.permission.level }}</strong>
|
||||
</article>
|
||||
<article class="semantic-result-card">
|
||||
<span>置信度</span>
|
||||
<strong>{{ semanticConfidenceLabel }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="semantic-field-list">
|
||||
<section>
|
||||
<h4>实体</h4>
|
||||
<p>{{ semanticEntitiesText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>时间</h4>
|
||||
<p>{{ semanticTimeRangeText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>指标</h4>
|
||||
<p>{{ semanticMetricsText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>约束</h4>
|
||||
<p>{{ semanticConstraintsText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>风险</h4>
|
||||
<p>{{ semanticRiskFlagsText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>澄清</h4>
|
||||
<p>{{ semanticClarificationText }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="semantic-json-block">
|
||||
<h4>原始 JSON</h4>
|
||||
<pre>{{ semanticResultJson }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel info-panel hot-top-panel">
|
||||
<header>
|
||||
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
|
||||
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
|
||||
</header>
|
||||
<div class="top-question-list">
|
||||
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
|
||||
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
|
||||
<span>{{ item }}</span>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel info-panel similar-panel">
|
||||
<header>
|
||||
<h3>相似历史问题</h3>
|
||||
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</header>
|
||||
<div class="similar-scroll">
|
||||
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
|
||||
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
|
||||
<strong>{{ item.score }}</strong>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/ChatView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/chat-view.css"></style>
|
||||
<template>
|
||||
<section class="qa-view">
|
||||
<div class="qa-layout">
|
||||
<aside class="left-column">
|
||||
<article class="panel side-panel conversation-list">
|
||||
<header>
|
||||
<h3>问答会话</h3>
|
||||
<button class="outline-action" type="button" @click="emit('draft', '')">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新建会话</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="session-scroll">
|
||||
<button
|
||||
v-for="item in sessions"
|
||||
:key="item.title"
|
||||
class="session-row"
|
||||
:class="{ active: item.active }"
|
||||
type="button"
|
||||
@click="applyPrompt(item.title)"
|
||||
>
|
||||
<span><i class="mdi mdi-message-processing-outline"></i></span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<time>{{ item.time }}</time>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<article class="panel chat-panel">
|
||||
<div ref="localMessageList" class="message-stream" aria-live="polite">
|
||||
<div class="talk-row user">
|
||||
<span class="avatar user-avatar">张</span>
|
||||
<div class="talk-content">
|
||||
<header><strong>张明</strong><time>10:32</time></header>
|
||||
<p class="user-question">北京出差,酒店超标报销怎么处理?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="talk-row assistant">
|
||||
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
||||
<div class="talk-content">
|
||||
<header><strong>财务AI助手</strong><time>10:32</time></header>
|
||||
<div class="answer-card">
|
||||
<section>
|
||||
<h4>结论</h4>
|
||||
<p>酒店费用超过标准的部分原则上不予报销,特殊情况可申请例外报销。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>处理建议</h4>
|
||||
<ul>
|
||||
<li>超标部分由个人自理或按制度退回,保留超标说明和相关凭证。</li>
|
||||
<li>符合公司相关政策的,可提交佐证材料,申请例外报销。</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>适用规则</h4>
|
||||
<ul>
|
||||
<li>《差旅报销管理办法(2024版)》第十二条:住宿标准及超标处理</li>
|
||||
<li>《费用报销审批流程》附件1:国内差旅住宿标准</li>
|
||||
</ul>
|
||||
</section>
|
||||
<footer>
|
||||
<span>是否有帮助?</span>
|
||||
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
|
||||
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="talk-row user">
|
||||
<span class="avatar user-avatar">张</span>
|
||||
<div class="talk-content">
|
||||
<header><strong>张明</strong><time>10:35</time></header>
|
||||
<p class="user-question">如果出差地公司名称不一致还能报销吗?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="talk-row assistant">
|
||||
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
|
||||
<div class="talk-content">
|
||||
<header><strong>财务AI助手</strong><time>10:35</time></header>
|
||||
<div class="answer-card compact">
|
||||
<section>
|
||||
<h4>结论</h4>
|
||||
<p>一般情况下,差旅地与参会公司名称不一致需按异常处理,建议提供情况说明并加盖公章或补充邀请材料。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>适用规则</h4>
|
||||
<ul>
|
||||
<li>《发票管理规定及失误销细则》第二章:发票基本要求</li>
|
||||
<li>《差旅报销管理办法》附件1:报销凭证要求</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
|
||||
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
|
||||
<template v-if="message.role === 'user'">我</template>
|
||||
<i v-else class="mdi mdi-robot-outline"></i>
|
||||
</span>
|
||||
<div class="talk-content">
|
||||
<header>
|
||||
<strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong>
|
||||
<time>刚刚</time>
|
||||
</header>
|
||||
<p v-if="message.role === 'user'" class="user-question">{{ message.text }}</p>
|
||||
<div v-else class="agent-answer-content">
|
||||
<template v-for="(block, blockIndex) in buildAnswerBlocks(message.text)" :key="`${message.id}-block-${blockIndex}`">
|
||||
<p v-if="block.type === 'paragraph'" class="agent-answer">{{ block.text }}</p>
|
||||
<div v-else-if="block.type === 'table'" class="agent-answer-table-wrap">
|
||||
<table class="agent-answer-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="header in block.headers" :key="`${message.id}-head-${header}`">{{ header }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in block.rows" :key="`${message.id}-row-${rowIndex}`">
|
||||
<td v-for="(cell, cellIndex) in row" :key="`${message.id}-cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
|
||||
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
|
||||
</div>
|
||||
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
|
||||
<strong>风险标签</strong>
|
||||
<div class="agent-detail-chip-row">
|
||||
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<details v-if="message.role !== 'user' && message.citations?.length" class="agent-detail-block agent-citation-disclosure">
|
||||
<summary>
|
||||
<strong>引用依据</strong>
|
||||
<span>{{ message.citations.length }} 项</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</summary>
|
||||
<div class="agent-citation-list">
|
||||
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="agent-citation-card">
|
||||
<header>
|
||||
<span>{{ item.title }}</span>
|
||||
<small>{{ item.version || item.source_type }}</small>
|
||||
</header>
|
||||
<p>{{ item.excerpt || item.code }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</details>
|
||||
<div v-if="message.role !== 'user' && message.draftPayload" class="agent-draft-card">
|
||||
<header>
|
||||
<strong>{{ message.draftPayload.title }}</strong>
|
||||
<span>待人工确认</span>
|
||||
</header>
|
||||
<pre>{{ message.draftPayload.body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="prompt-toolbar">
|
||||
<span>猜你想问</span>
|
||||
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
|
||||
<i :class="prompt.icon"></i>
|
||||
{{ prompt.short }}
|
||||
</button>
|
||||
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<textarea
|
||||
:value="draft"
|
||||
rows="2"
|
||||
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
|
||||
:disabled="sending"
|
||||
@input="emit('draft', $event.target.value)"
|
||||
@keydown.ctrl.enter.prevent="emit('send')"
|
||||
></textarea>
|
||||
<button
|
||||
class="send-button"
|
||||
type="button"
|
||||
aria-label="发送问题"
|
||||
:disabled="sending || !String(draft || '').trim()"
|
||||
@click="emit('send')"
|
||||
>
|
||||
<i :class="sending ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="right-column">
|
||||
<article class="panel info-panel semantic-debug-panel">
|
||||
<header>
|
||||
<h3><i class="mdi mdi-shape-outline"></i> 语义解析调试</h3>
|
||||
<button type="button" @click="useDraftAsSemanticInput">带入输入框</button>
|
||||
</header>
|
||||
|
||||
<div class="semantic-debug-body">
|
||||
<label class="semantic-debug-input">
|
||||
<span>自然语言问题</span>
|
||||
<textarea
|
||||
v-model="semanticDraft"
|
||||
rows="4"
|
||||
placeholder="例如:查一下本周报销超标风险"
|
||||
@keydown.ctrl.enter.prevent="parseSemanticQuery"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="semantic-debug-actions">
|
||||
<button
|
||||
v-for="item in semanticExamples"
|
||||
:key="item"
|
||||
class="semantic-chip"
|
||||
type="button"
|
||||
@click="applySemanticExample(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="semantic-debug-toolbar">
|
||||
<button class="semantic-parse-btn" type="button" :disabled="semanticLoading" @click="parseSemanticQuery">
|
||||
<i :class="semanticLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-play-circle-outline'"></i>
|
||||
<span>{{ semanticLoading ? '解析中...' : '开始解析' }}</span>
|
||||
</button>
|
||||
<span class="semantic-inline-meta">
|
||||
<template v-if="semanticResult">run_id:{{ semanticResult.run_id }}</template>
|
||||
<template v-else>支持 Ctrl + Enter</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="semanticError" class="semantic-debug-error">{{ semanticError }}</p>
|
||||
|
||||
<div v-if="semanticResult" class="semantic-result-stack">
|
||||
<div class="semantic-result-grid">
|
||||
<article class="semantic-result-card">
|
||||
<span>场景</span>
|
||||
<strong>{{ semanticResult.scenario }}</strong>
|
||||
</article>
|
||||
<article class="semantic-result-card">
|
||||
<span>意图</span>
|
||||
<strong>{{ semanticResult.intent }}</strong>
|
||||
</article>
|
||||
<article class="semantic-result-card">
|
||||
<span>权限</span>
|
||||
<strong>{{ semanticResult.permission.level }}</strong>
|
||||
</article>
|
||||
<article class="semantic-result-card">
|
||||
<span>置信度</span>
|
||||
<strong>{{ semanticConfidenceLabel }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="semantic-field-list">
|
||||
<section>
|
||||
<h4>实体</h4>
|
||||
<p>{{ semanticEntitiesText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>时间</h4>
|
||||
<p>{{ semanticTimeRangeText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>指标</h4>
|
||||
<p>{{ semanticMetricsText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>约束</h4>
|
||||
<p>{{ semanticConstraintsText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>风险</h4>
|
||||
<p>{{ semanticRiskFlagsText }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h4>澄清</h4>
|
||||
<p>{{ semanticClarificationText }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="semantic-json-block">
|
||||
<h4>原始 JSON</h4>
|
||||
<pre>{{ semanticResultJson }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel info-panel hot-top-panel">
|
||||
<header>
|
||||
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
|
||||
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
|
||||
</header>
|
||||
<div class="top-question-list">
|
||||
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
|
||||
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
|
||||
<span>{{ item }}</span>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel info-panel similar-panel">
|
||||
<header>
|
||||
<h3>相似历史问题</h3>
|
||||
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
||||
</header>
|
||||
<div class="similar-scroll">
|
||||
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
|
||||
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
|
||||
<strong>{{ item.score }}</strong>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/ChatView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/chat-view.css"></style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,69 @@
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
|
||||
function isMarkdownTableDivider(line = '') {
|
||||
const value = String(line || '').trim()
|
||||
if (!value.includes('|')) return false
|
||||
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value)
|
||||
}
|
||||
|
||||
function splitMarkdownTableRow(line = '') {
|
||||
return String(line || '')
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
function buildAnswerBlocks(text = '') {
|
||||
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index].trim()
|
||||
if (!line) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
line.includes('|') &&
|
||||
index + 1 < lines.length &&
|
||||
isMarkdownTableDivider(lines[index + 1])
|
||||
) {
|
||||
const headers = splitMarkdownTableRow(line)
|
||||
const rows = []
|
||||
index += 2
|
||||
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
|
||||
rows.push(splitMarkdownTableRow(lines[index]))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'table', headers, rows })
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraphLines = [line]
|
||||
index += 1
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!(
|
||||
lines[index].includes('|') &&
|
||||
index + 1 < lines.length &&
|
||||
isMarkdownTableDivider(lines[index + 1])
|
||||
)
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim())
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') })
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ChatView',
|
||||
props: {
|
||||
@@ -170,7 +233,9 @@ export default {
|
||||
role_codes: currentUser.value?.roleCodes || [],
|
||||
is_admin: Boolean(currentUser.value?.isAdmin),
|
||||
name: currentUser.value?.name || '',
|
||||
role: currentUser.value?.role || ''
|
||||
role: currentUser.value?.role || '',
|
||||
position: currentUser.value?.position || '',
|
||||
grade: currentUser.value?.grade || ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -212,6 +277,7 @@ export default {
|
||||
semanticRiskFlagsText,
|
||||
semanticClarificationText,
|
||||
semanticResultJson,
|
||||
buildAnswerBlocks,
|
||||
applySemanticExample,
|
||||
useDraftAsSemanticInput,
|
||||
parseSemanticQuery
|
||||
|
||||
@@ -228,6 +228,69 @@ function createMessage(role, text, attachments = [], extras = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMarkdownTableDivider(line = '') {
|
||||
const value = String(line || '').trim()
|
||||
if (!value.includes('|')) return false
|
||||
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value)
|
||||
}
|
||||
|
||||
function splitMarkdownTableRow(line = '') {
|
||||
return String(line || '')
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
function buildAnswerBlocks(text = '') {
|
||||
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
|
||||
const blocks = []
|
||||
let index = 0
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index].trim()
|
||||
if (!line) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
line.includes('|') &&
|
||||
index + 1 < lines.length &&
|
||||
isMarkdownTableDivider(lines[index + 1])
|
||||
) {
|
||||
const headers = splitMarkdownTableRow(line)
|
||||
const rows = []
|
||||
index += 2
|
||||
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
|
||||
rows.push(splitMarkdownTableRow(lines[index]))
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'table', headers, rows })
|
||||
continue
|
||||
}
|
||||
|
||||
const paragraphLines = [line]
|
||||
index += 1
|
||||
while (
|
||||
index < lines.length &&
|
||||
lines[index].trim() &&
|
||||
!(
|
||||
lines[index].includes('|') &&
|
||||
index + 1 < lines.length &&
|
||||
isMarkdownTableDivider(lines[index + 1])
|
||||
)
|
||||
) {
|
||||
paragraphLines.push(lines[index].trim())
|
||||
index += 1
|
||||
}
|
||||
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') })
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function formatMessageTime(value) {
|
||||
if (!value) {
|
||||
return nowTime()
|
||||
@@ -3371,6 +3434,8 @@ export default {
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
@@ -3787,6 +3852,7 @@ export default {
|
||||
buildReviewRiskHint,
|
||||
buildReviewActionHint,
|
||||
buildReviewStatusTag,
|
||||
buildAnswerBlocks,
|
||||
buildExpenseQueryWindowLabel,
|
||||
buildExpenseQueryHint,
|
||||
getExpenseQueryActivePage,
|
||||
|
||||
Reference in New Issue
Block a user