摘要 :元宝搜索征文里的技术文章,前端部分基本停留在"输入框 → 结果列表"。我试着把"用户信不信 AI 的结论"这件事本身当成一个 Vue 组件问题来做:元宝返回的 Pages 是嵌套 JSON 字符串,我把它解析成证据卡片,每张卡片可以标"可信 / 存疑 / 过期",标注结果即时改变结论区的呈现方式。后端负责拿到证据,前端负责让用户亲手检查证据。
一、我为什么想在前端做这件事
平时写的最多的是"后端给了什么数据,前端怎么让它好看好用"。做过 el-select 下拉表格多选回显,做过拼音搜索,做过 vxe-table 动态列------都是同一类问题:数据是后端给的,但用户能不能真正操作它,取决于前端做了多少层翻译。
元宝搜索这个征文里,技术文章已经很多了。"政策过期检测器"靠三路取证判断政策是否现行,"端侧 RAG 可信外脑"把 SearchPro 接进本地检索链路,"企业合规舆情 Agent"用 Industry=gov 和 Freshness 做定时巡检。这些作品的重心全在检索逻辑和规则引擎,前端基本是一个输入框加一个结果列表。
没有人把"用户怎么验证搜索结果"当成一个前端问题来认真做。 后端把 10 条检索结果压缩成一段自然语言结论,用户看不到支撑这段结论的原始页面是什么、什么时候发布的、来源是谁。AI 没有说谎,但前端没有给用户"检查这句话是否成立"的工具。
我想试试把 "信任 AI 结论" 这件事本身拆成一个 Vue 组件问题。
二、整体架构:前端拿不到密钥,代理层只做一件事
元宝搜索 SearchPro 的接口本身不复杂:POST https://api.wsa.cloud.tencent.com/SearchPro,请求头带 Authorization: Bearer sk-xxx,请求体传 Query 以及可选的 Freshness、Industry、Mode 等参数。响应里 Response.Pages 是一个字符串数组 ,每个元素内部是 JSON 字符串,包含 title、url、passage、date、score、site 等字段。
但有一个硬边界:API KEY 不能进浏览器。 官方文档写得很清楚,服务密钥是安全鉴权凭证,"请勿通过任何方式上传或者公开分享您的密钥信息"。直接把 sk- 开头的 key 写进前端 fetch,等于把密钥发给每一个打开页面的用户。
所以代理层是必须的。我用 Express 写了一个只做鉴权转发的中间件,不缓存、不重试、不承载业务逻辑:
javascript
// proxy/index.js
const express = require('express');
const app = express();
app.use(express.json());
app.post('/search', async (req, res) => {
const { Query, Freshness, Industry, Mode } = req.body;
const body = { Query };
if (Freshness) body.Freshness = Freshness;
if (Industry) body.Industry = Industry;
if (Mode !== undefined) body.Mode = Mode;
const resp = await fetch('https://api.wsa.cloud.tencent.com/SearchPro', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.YUANBAO_API_KEY}`
},
body: JSON.stringify(body)
});
const data = await resp.json();
res.json(data);
});
app.listen(3000);
前端只调 /search,拿到的已经是脱敏后的 Response.Pages。如果不想自己维护 Express,腾讯云 API 网关也可以直接托管这个转发,配好 CORS 之后前端一行代理代码都不用写。
代理层是前端可控的信任边界------它不解决检索准确率,只保证密钥不进浏览器。
三、第一步就被 Pages 绊了一下
Response.Pages 的类型是 Array of String,每个元素不是对象,是序列化之后的 JSON 字符串 。直接 pages.map(p => p.title) 拿到的是 undefined。
这和之前处理 vxe-table 动态数据、el-cascader 回显是同一类问题:永远不要假设后端返回的结构是干净的。 写一个逐条解析、逐条兜底的工具函数:
javascript
// utils/parsePages.js
export function parsePages(pages) {
return (pages || []).map((raw, index) => {
try {
const obj = JSON.parse(raw);
return {
id: `ev-${index}`,
title: obj.title || '(无标题)',
url: obj.url || '',
passage: obj.passage || '',
site: obj.site || '',
publishedAt: obj.date || null,
freshnessBand: resolveBand(obj.date),
userVerdict: 'unreviewed',
conflictsWith: []
};
} catch (e) {
// 解析失败不能白屏,保留原始字符串供排查
return {
id: `ev-${index}`,
title: '解析失败',
url: '',
passage: raw,
site: '',
publishedAt: null,
freshnessBand: 'unknown',
userVerdict: 'unreviewed',
conflictsWith: []
};
}
});
}
function resolveBand(dateStr) {
if (!dateStr) return 'unknown';
const days = (Date.now() - new Date(dateStr).getTime()) / 86400000;
if (days <= 7) return 'd7';
if (days <= 30) return 'm1';
if (days <= 180) return 'm6';
if (days <= 730) return 'y2';
return 'old';
}
try/catch 必须逐条加,不能让一条格式异常的结果把整个列表打成白屏。date 字段不是每条都有,缺失时显示"时间未知",而不是留一个空字符串。
四、Freshness 参数和前端时间分档不是一回事
元宝搜索的 Freshness 参数控制的是检索范围 :d7 最近七天,m3 最近三月,y2 最近两年,也可以传具体日期区间。但这个参数回答的是"搜什么",前端还需要回答另一个问题:"这条证据在用户眼里有多新。"
我在前端用 publishedAt 自行推导 freshnessBand,而不是依赖 API 返回的元数据。展示上用一个圆点区分:7 天内绿色,1 个月内黄色,6 个月内橙色,超过 1 年灰色。CSS 大概是这样:
css
.freshness-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 8px; }
.freshness-dot.d7 { background: #67c23a; }
.freshness-dot.m1 { background: #e6a23c; }
.freshness-dot.m6 { background: #f56c6c; }
.freshness-dot.y2, .freshness-dot.old { background: #909399; }
.freshness-dot.unknown { background: #c0c4cc; }
搜索参数是筛子 ,前端分档是尺子 。筛子决定什么进来,尺子决定用户怎么看待进来的东西。把 Freshness 传给后端就结束了,等于只做了筛子,没做尺子。
五、证据卡片:el-collapse-transition 加三态标注
每一条证据渲染成一张 el-card,头部默认只显示时效圆点、标题和发布时间,点开后才露出 passage 摘要和原文链接。展开动画用 Element UI 自带的 el-collapse-transition,不需要引额外组件。
vue
<!-- components/EvidenceCard.vue -->
<template>
<el-card :class="['evidence-card', `verdict-${item.userVerdict}`]" shadow="never">
<div slot="header" class="card-header" @click="expanded = !expanded">
<span :class="['freshness-dot', item.freshnessBand]"></span>
<span class="ev-title">{{ item.title }}</span>
<span class="ev-date">{{ item.publishedAt || '时间未知' }}</span>
<i :class="expanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
</div>
<el-collapse-transition>
<div v-show="expanded" class="card-body">
<p class="ev-passage">{{ item.passage }}</p>
<div class="ev-meta">
<span v-if="item.site" class="ev-site">{{ item.site }}</span>
<a :href="item.url" target="_blank" class="ev-link">查看原文 →</a>
</div>
<div class="ev-actions">
<el-button size="mini" :type="item.userVerdict === 'trusted' ? 'success' : ''"
@click.stop="$emit('verdict', item.id, 'trusted')">可信</el-button>
<el-button size="mini" :type="item.userVerdict === 'questioned' ? 'warning' : ''"
@click.stop="$emit('verdict', item.id, 'questioned')">存疑</el-button>
<el-button size="mini" :type="item.userVerdict === 'expired' ? 'info' : ''"
@click.stop="$emit('verdict', item.id, 'expired')">标记过期</el-button>
<el-button size="mini" plain @click.stop="$emit('conflict', item.id)">标记冲突</el-button>
</div>
</div>
</el-collapse-transition>
</el-card>
</template>
<script>
export default {
props: { item: { type: Object, required: true } },
data() { return { expanded: false }; }
};
</script>
<style scoped>
.evidence-card { margin-bottom: 12px; border-left: 3px solid #dcdfe6; }
.evidence-card.verdict-trusted { border-left-color: #67c23a; }
.evidence-card.verdict-questioned { border-left-color: #e6a23c; background: #fdf6ec; }
.evidence-card.verdict-expired { border-left-color: #909399; opacity: 0.65; }
.card-header { cursor: pointer; display: flex; align-items: center; }
.ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; }
.ev-date { color: #909399; font-size: 12px; margin-right: 12px; }
.ev-actions { margin-top: 12px; }
</style>
三态标注(trusted / questioned / expired)通过 border-left-color 和背景色改变卡片视觉状态。用户的操作是纯前端本地状态,不触发二次 API 调用。 用 Vuex 或 provide/inject 管理 evidenceList,刷新即重置。不做持久化,是因为这个作品验证的是"单次核查会话"中的信任建构过程,引入账号体系和后端存储反而偏离了前端体验验证的核心。
六、冲突标记:让前端成为"对抗性测试"的触发器
这是整个作品里最有记忆点的交互。用户可以在任意两张卡片上点击"标记冲突",前端把两条证据的 id 写入彼此的 conflictsWith,并在结论区弹出一条 el-alert:
⚠️ 你标记了 2 条证据存在矛盾,当前结论未检测到这一冲突。
查看冲突证据\] \[基于冲突重新判断
"基于冲突重新判断"是一个可选的后端调用:把两条冲突证据的 passage 发给一个轻量 LLM,要求它"基于这两条互相矛盾的证据,给出一个承认不确定性的回答"。如果不接 LLM,前端也可以纯本地渲染一条模板提示:"现有证据存在矛盾,建议交叉验证后再采信。"
重点是:冲突的触发权在前端。 用户不需要等后端规则引擎跑完才被告知"哦这里有矛盾"。他可以自己制造一次对抗性测试,然后观察 AI 结论会怎么变化。
七、演示界面长什么样
三栏太重,上下结构就够。上面是查询区和结论区,下面是证据卡片列表:
text
┌──────────────────────────────────────────────────────────────┐
│ [查询输入] 时效: [近6月 ▼] 垂类: [通用 ▼] [核查] │
├──────────────────────────────────────────────────────────────┤
│ AI 结论(折叠态) │
│ "该型号在 2025 年有召回记录......" [展开证据链] │
│ ⚠️ 你标记了 1 组冲突 [查看] [基于冲突重新判断] │
├──────────────────────────────────────────────────────────────┤
│ 证据卡片列表 (3/8 已核查) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🟢 市场监管总局公告... 2025-11-02 │ │
│ │ [可信] [存疑] [标记过期] [标记冲突] │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🟠 某资讯站报道... 2024-06-15 │ │
│ │ [可信] [存疑⚠️] [标记过期] [标记冲突] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
技术栈就是 Vue 2 + Element UI + Vuex(或 provide/inject)。不需要图表库,不需要额外的 UI 框架。el-collapse-transition、el-button、el-alert、el-select,全是 Element UI 自带的东西。
八、踩坑清单
坑一:Pages 是字符串套字符串。 不是对象数组,是 Array<String>,每条都要 JSON.parse。逐条 try/catch,解析失败显示"解析失败"而不是让整个列表崩掉。
坑二:date 字段会缺。 部分结果没有发布时间,前端必须有兜底显示"时间未知",不能留空,也不能因为 null 让 resolveBand 抛异常。
坑三:极速版会静默忽略 Freshness 和 Industry。 极速版只接受 Query(和 Site),其他参数不会被报错,只会被服务端直接忽略。如果演示用的是极速版,不要把时效选择器和垂类选择器当成"生效功能"来展示,否则评审复现时会发现参数根本没起作用。需要这两个能力,得升级到标准版或旗舰版。
坑四:score 的量纲会变。 默认模式下 score 落在 0~1,但加上 Industry 参数之后,实测分数量纲会超出文档描述的上界(新闻垂类最高到过 3 以上)。前端不要写 if (score > 0.8) 采信 这类绝对阈值逻辑,score 只适合做相对排序,不适合当置信度用。
坑五:authority_level 只有旗舰版有。 如果前端想根据权威度做二次排序,得先确认当前版本。标准版不返回这个字段,代码里不能假设它一定存在。
九、这个作品证明了什么
元宝搜索让 AI 的答案"有源可查"。后端通过 Freshness、Industry 和 Pages 把证据拿回来了,但证据的可信不等于用户感知到的可信。从"10 条原始结果"到"用户愿意相信的一段结论",中间那层翻译工作,是前端的事。
证据折叠不解决检索准确率,也不替代模型判断。它只做一件事:把"信不信"的控制权从 AI 手里拿回来,放进用户的按钮里。每一条 AI 判断旁边都有一组可以逐条展开、标注、甚至制造冲突的证据卡片。用户可以亲手检查 AI 到底凭什么这么说------而不是由 AI 宣称自己可信。
这个作品的全部代码,最复杂的部分是一个 JSON.parse 加一个 el-collapse-transition。但它把"搜索结果的证据结构"变成了一个用户可以操作的前端对象。如果你也在写元宝搜索的前端,不妨从 Pages 解析开始,先让证据能展开,再让用户能标注,最后让结论跟着标注变。三步下来,前端在这个链路里的位置就清楚了。