
摘要元宝搜索征文里的技术文章前端部分基本停留在“输入框 → 结果列表”。我试着把“用户信不信 AI 的结论”这件事本身当成一个 Vue 组件问题来做元宝返回的Pages是嵌套 JSON 字符串我把它解析成证据卡片每张卡片可以标“可信 / 存疑 / 过期”标注结果即时改变结论区的呈现方式。后端负责拿到证据前端负责让用户亲手检查证据。一、我为什么想在前端做这件事平时写得最多的是“后端给了什么数据前端怎么让它好看好用”。做过el-select下拉表格多选回显做过拼音搜索做过vxe-table动态列——都是同一类问题数据是后端给的但用户能不能真正操作它取决于前端做了多少层翻译。元宝搜索这个征文里技术文章已经很多了。“政策过期检测器”靠三路取证判断政策是否现行“端侧 RAG 可信外脑”把 SearchPro 接进本地检索链路“企业合规舆情 Agent”用Industrygov和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 写了一个只做鉴权转发的中间件不缓存、不重试、不承载业务逻辑// proxy/index.jsconstexpressrequire(express);constappexpress();app.use(express.json());app.post(/search,async(req,res){const{Query,Freshness,Industry,Mode}req.body;constbody{Query};if(Freshness)body.FreshnessFreshness;if(Industry)body.IndustryIndustry;if(Mode!undefined)body.ModeMode;constrespawaitfetch(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)});constdataawaitresp.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回显是同一类问题永远不要假设后端返回的结构是干净的。写一个逐条解析、逐条兜底的工具函数// utils/parsePages.jsexportfunctionparsePages(pages){return(pages||[]).map((raw,index){try{constobjJSON.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:[]};}});}functionresolveBand(dateStr){if(!dateStr)returnunknown;constdays(Date.now()-newDate(dateStr).getTime())/86400000;if(days7)returnd7;if(days30)returnm1;if(days180)returnm6;if(days730)returny2;returnold;}try/catch必须逐条加不能让一条格式异常的结果把整个列表打成白屏。date字段不是每条都有缺失时显示“时间未知”而不是留一个空字符串。四、Freshness参数和前端时间分档不是一回事元宝搜索的Freshness参数控制的是检索范围d7最近七天m3最近三月y2最近两年也可以传具体日期区间。但这个参数回答的是“搜什么”前端还需要回答另一个问题“这条证据在用户眼里有多新。”我在前端用publishedAt自行推导freshnessBand而不是依赖 API 返回的元数据。展示上用一个圆点区分7 天内绿色1 个月内黄色6 个月内橙色超过 1 年灰色。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不需要引额外组件。!-- components/EvidenceCard.vue -- template el-card :class[evidence-card, verdict-${item.userVerdict}] shadownever div slotheader classcard-header clickexpanded !expanded span :class[freshness-dot, item.freshnessBand]/span span classev-title{{ item.title }}/span span classev-date{{ item.publishedAt || 时间未知 }}/span i :classexpanded ? el-icon-arrow-up : el-icon-arrow-down/i /div el-collapse-transition div v-showexpanded classcard-body p classev-passage{{ item.passage }}/p div classev-meta span v-ifitem.site classev-site{{ item.site }}/span a :hrefitem.url target_blank classev-link查看原文 →/a /div div classev-actions el-button sizemini :typeitem.userVerdict trusted ? success : click.stop$emit(verdict, item.id, trusted)可信/el-button el-button sizemini :typeitem.userVerdict questioned ? warning : click.stop$emit(verdict, item.id, questioned)存疑/el-button el-button sizemini :typeitem.userVerdict expired ? info : click.stop$emit(verdict, item.id, expired)标记过期/el-button el-button sizemini 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 结论会怎么变化。七、演示界面长什么样三栏太重上下结构就够。上面是查询区和结论区下面是证据卡片列表┌──────────────────────────────────────────────────────────────┐ │ [查询输入] 时效: [近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 是字符串套字符串 | 逐条 JSON.parse try/catch失败显示“解析失败” | | 坑二 | date 字段会缺 | 兜底显示“时间未知”resolveBand 判空 | | 坑三 | 极速版静默忽略 Freshness / Industry | 演示前确认版本或升级标准版/旗舰版 | | 坑四 | score 量纲会变 | 只做相对排序不写绝对阈值 | | 坑五 | authority_level 只有旗舰版有 | 先确认版本代码不假设字段存在 | 下面把每个坑展开说一下方便复现时对照排查。 **坑一Pages 是字符串套字符串。** 不是对象数组是 ArrayString每条都要 JSON.parse。逐条 try/catch解析失败显示“解析失败”而不是让整个列表崩掉。 **坑二date 字段会缺。** 部分结果没有发布时间前端必须有兜底显示“时间未知”不能留空也不能因为 null 让 resolveBand 抛异常。 **坑三极速版会静默忽略 Freshness 和 Industry。** 极速版只接受 Query和 Site其他参数不会被报错只会被服务端直接忽略。如果演示用的是极速版不要把时效选择器和垂类选择器当成“生效功能”来展示否则评审复现时会发现参数根本没起作用。需要这两个能力得升级到标准版或旗舰版。 **坑四score 的量纲会变。** 默认模式下 score 落在 01但加上 Industry 参数之后实测分数量纲会超出文档描述的上界新闻垂类最高到过 3 以上。前端不要写 if (score 0.8) 采信 这类绝对阈值逻辑score 只适合做相对排序不适合当置信度用。 **坑五authority_level 只有旗舰版有。** 如果前端想根据权威度做二次排序得先确认当前版本。标准版不返回这个字段代码里不能假设它一定存在。。 ## 九、这个作品证明了什么元宝搜索让 AI 的答案“有源可查”。后端通过 Freshness、Industry 和 Pages 把证据拿了回来但证据可信不等于用户感知到的可信。**从“10 条原始结果”到“用户愿意相信的一段结论”中间那层翻译工作是前端的事。*** 证据折叠不解决检索准确率也不替代模型判断。它只做一件事把“信不信”的控制权从 AI 手里拿回来放进用户的按钮里。每一条 AI 判断旁边都有一组可以逐条展开、标注、甚至制造冲突的证据卡片。用户可以亲手检查 AI 到底凭什么这么说——**而不是由 AI 宣称自己可信。**这个作品的全部代码最复杂的部分是一个 JSON.parse 加一个 el-collapse-transition。但它把“搜索结果的证据结构”变成了一个用户可以操作的前端对象。如果你也在写元宝搜索的前端不妨从 Pages 解析开始先让证据能展开再让用户能标注最后让结论跟着标注变。三步下来前端在这个链路里的位置就清楚了。。