Skip to content
rookie_L
Go back

Agent 产品的信任感,来自前端的三个确认点

Edit page

Agent 产品经常被描述成“自动完成任务”。但自动化程度越高,用户越容易产生悬空感:它依据什么得出这个推论?它刚才以谁的名义调用了外部接口?改完这几行代码或发完这封邮件后,我能不能撤销?

信任感不是在页面上贴一句“AI 生成的内容仅供参考”就能获得的,而是由前端界面持续提供清晰、可核验的确认点。把一次黑盒调用拆解为有据可查、有界可控、有果可验的过程,用户才敢把真实业务交给它。

第一个确认点:它参考了什么(来源可核实)

涉及知识库检索、网络搜索或项目工程分析时,模型输出的核心断言必须伴随溯源凭据。直接在正文中用蓝色文字模拟超链接是极其不可靠的,界面应当将模型返回的 Citation 结构化,并提供预览抽屉或悬浮卡片。

引用数据结构与前端展示

export type CitationSource = {
  id: string;
  title: string;
  url?: string;
  excerpt: string;
  sourceType: "file" | "web" | "doc" | "database";
  updatedAt?: string;
  confidenceScore?: number;
};

export type MessageWithCitations = {
  id: string;
  role: "assistant";
  content: string;
  citations: CitationSource[];
};

当正文中出现引用标记(如 [^1])时,前端渲染器应将其映射为可交互的引用徽标,并在底部或右侧抽屉展示原文对照:

import React, { useState } from "react";

export function CitationSourceList({ sources }: { sources: CitationSource[] }) {
  const [activeId, setActiveId] = useState<string | null>(null);

  if (sources.length === 0) {
    return (
      <div className="mt-2 flex items-center gap-1.5 text-xs text-neutral-400">
        <span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
        当前回答由模型通用知识生成,未检索到特定外部来源
      </div>
    );
  }

  return (
    <div className="mt-3 border-t border-neutral-100 pt-2 dark:border-neutral-800">
      <div className="mb-1.5 flex items-center justify-between text-xs font-medium text-neutral-500">
        <span>参考依据({sources.length} 处来源)</span>
      </div>
      <div className="flex flex-wrap gap-2">
        {sources.map((source, index) => (
          <button
            key={source.id}
            type="button"
            onClick={() =>
              setActiveId(activeId === source.id ? null : source.id)
            }
            className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-all ${
              activeId === source.id
                ? "border-blue-300 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-950/40 dark:text-blue-300"
                : "border-neutral-200 bg-neutral-50 text-neutral-600 hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300"
            }`}
          >
            <span className="font-mono text-[10px] opacity-70">
              [{index + 1}]
            </span>
            <span className="max-w-[140px] truncate">{source.title}</span>
          </button>
        ))}
      </div>

      {activeId && (
        <div className="animate-fadeIn mt-2.5 rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-xs dark:border-neutral-800 dark:bg-neutral-900">
          {(() => {
            const current = sources.find(s => s.id === activeId);
            if (!current) return null;
            return (
              <>
                <div className="mb-1 flex items-center justify-between font-medium text-neutral-500">
                  <span className="capitalize">
                    来源类型: {current.sourceType}
                  </span>
                  {current.updatedAt && (
                    <span>更新时间: {current.updatedAt}</span>
                  )}
                </div>
                <blockquote className="my-1.5 rounded border-l-2 border-blue-500 bg-white p-2 pl-2 text-neutral-700 italic dark:bg-neutral-950 dark:text-neutral-300">
{current.excerpt}
                </blockquote>
                {current.url && (
                  <a
                    href={current.url}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="mt-1 flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
                  >
                    查看原文 ↗
                  </a>
                )}
              </>
            );
          })()}
        </div>
      )}
    </div>
  );
}

没有可靠来源时,明确显示“未找到可验证来源”,好过渲染一段假链接。前端的职责不是替模型掩盖无据推论,而是把事实依据暴露在阳光下。


第二个确认点:它准备以谁的身份做什么(动作与权限预审)

绝大部分自动化故障发生在“产生副作用”的瞬间:向生产环境推送代码、给外部客户发邮件、直接修改数据库记录。只读查询与修改操作有着本质不同的风险等级。

前端应当把抽象的模型函数调用(tool_call)转换成具备明确语义的审批卡片,并在关键操作发生前阻断执行流程。

风险分级与审批卡片实现

export type RiskLevel = "low" | "medium" | "high" | "destructive";

export type ApprovalRequest = {
  callId: string;
  actionName: string;
  riskLevel: RiskLevel;
  summary: string;
  targetResource: string;
  diffPayload?: {
    filename: string;
    additions: number;
    deletions: number;
    preview: string;
  };
  expiresAt: number;
};

export function ActionApprovalCard({
  request,
  onApprove,
  onReject,
}: {
  request: ApprovalRequest;
  onApprove: (callId: string) => void;
  onReject: (callId: string, reason: string) => void;
}) {
  const [rejectReason, setRejectReason] = useState("");
  const [showRejectInput, setShowRejectInput] = useState(false);

  const riskBadgeStyles: Record<RiskLevel, string> = {
    low: "bg-emerald-50 text-emerald-700 border-emerald-200",
    medium: "bg-amber-50 text-amber-700 border-amber-200",
    high: "bg-orange-50 text-orange-700 border-orange-200",
    destructive: "bg-rose-50 text-rose-700 border-rose-200 font-semibold",
  };

  return (
    <div className="my-3 rounded-xl border-2 border-amber-300 bg-amber-50/30 p-4 dark:border-amber-700 dark:bg-amber-950/20">
      <div className="mb-2 flex items-start justify-between gap-2">
        <div className="flex items-center gap-2">
          <span className="text-lg">🛡️</span>
          <div>
            <h4 className="text-sm font-bold text-neutral-900 dark:text-neutral-100">
              需要操作授权:{request.actionName}
            </h4>
            <p className="text-xs text-neutral-500">
              目标资源:{request.targetResource}
            </p>
          </div>
        </div>
        <span
          className={`rounded-full border px-2 py-0.5 text-[11px] ${riskBadgeStyles[request.riskLevel]}`}
        >
          {request.riskLevel.toUpperCase()}
        </span>
      </div>

      <p className="mb-3 text-xs text-neutral-700 dark:text-neutral-300">
        {request.summary}
      </p>

      {request.diffPayload && (
        <div className="mb-3 overflow-x-auto rounded-lg bg-neutral-900 p-3 font-mono text-xs text-neutral-200">
          <div className="mb-2 flex justify-between border-b border-neutral-700 pb-1 text-[11px] text-neutral-400">
            <span>{request.diffPayload.filename}</span>
            <span>
              <span className="text-emerald-400">
                +{request.diffPayload.additions}
              </span>{" "}
              <span className="text-rose-400">
                -{request.diffPayload.deletions}
              </span>
            </span>
          </div>
          <pre className="whitespace-pre">{request.diffPayload.preview}</pre>
        </div>
      )}

      {showRejectInput ? (
        <div className="mt-2 flex items-center gap-2">
          <input
            type="text"
            placeholder="说明拒绝或修改原因(可选)..."
            value={rejectReason}
            onChange={e => setRejectReason(e.target.value)}
            className="flex-1 rounded-md border border-neutral-300 bg-white px-2.5 py-1.5 text-xs dark:border-neutral-700 dark:bg-neutral-900"
          />
          <button
            type="button"
            onClick={() => onReject(request.callId, rejectReason)}
            className="rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-rose-700"
          >
            确认拒绝
          </button>
          <button
            type="button"
            onClick={() => setShowRejectInput(false)}
            className="px-2 py-1.5 text-xs text-neutral-500 hover:text-neutral-700"
          >
            取消
          </button>
        </div>
      ) : (
        <div className="flex items-center justify-end gap-2.5 pt-1">
          <button
            type="button"
            onClick={() => setShowRejectInput(true)}
            className="rounded-md px-3 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
          >
            拒绝 / 修改指示
          </button>
          <button
            type="button"
            onClick={() => onApprove(request.callId)}
            className="flex items-center gap-1.5 rounded-md bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-blue-700"
          >
            <span>批准执行</span>
          </button>
        </div>
      )}
    </div>
  );
}

确认按钮文案一定要精准:“批准修改 3 个文件”、“发送邮件至 hr@company.com”,绝不能用模糊的“确认”、“好的”、“继续”。只有把操作客体具体化,用户才会认真阅读并审慎授权。


第三个确认点:它最终交付了什么(交付物与变更核验)

完成一次复杂的 Agent Run 之后,界面绝不能仅留下一行“任务已完成”和一堆无从核实的散落文字。高质量的交付形态,必须包含一份结构化的成果核验卡片(Delivery Checklist)

type ChecklistItem = {
  label: string;
  status: "verified" | "warning" | "manual_check_required";
  detail?: string;
};

export function ResultSummaryCard({
  title,
  summary,
  items,
  artifacts,
}: {
  title: string;
  summary: string;
  items: ChecklistItem[];
  artifacts?: { name: string; type: string; downloadUrl: string }[];
}) {
  const iconMap = {
    verified: "",
    warning: "⚠️",
    manual_check_required: "🔍",
  };

  return (
    <div className="mt-4 rounded-xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
      <h3 className="mb-1 text-sm font-semibold text-neutral-900 dark:text-neutral-100">
        {title}
      </h3>
      <p className="mb-3 text-xs text-neutral-600 dark:text-neutral-400">
        {summary}
      </p>

      <div className="space-y-1.5 rounded-lg border border-neutral-100 bg-neutral-50 p-2.5 dark:border-neutral-800 dark:bg-neutral-950">
        {items.map((item, i) => (
          <div key={i} className="flex items-start gap-2 text-xs">
            <span>{iconMap[item.status]}</span>
            <div className="flex-1">
              <span className="font-medium text-neutral-800 dark:text-neutral-200">
                {item.label}
              </span>
              {item.detail && (
                <span className="ml-1.5 text-neutral-500">({item.detail})</span>
              )}
            </div>
          </div>
        ))}
      </div>

      {artifacts && artifacts.length > 0 && (
        <div className="mt-3 flex items-center gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-800">
          <span className="text-xs font-medium text-neutral-500">
            生成产物:
          </span>
          {artifacts.map((art, idx) => (
            <a
              key={idx}
              href={art.downloadUrl}
              className="flex items-center gap-1 rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-700 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300"
            >
              📄 {art.name}
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

这份核验清单把“哪些通过了自动化校验”、“哪些存在未捕获的风险”、“哪些必须由人工最终确认”清晰隔开。这赋予了用户明确的边界感——他们知道自己还需要对什么负责。


不要把安全边界托付给前端代码

前端组件负责展示与收集决策,但真正的权限防线永远在服务端

  1. 防重放与一次性审批令牌(One-Time Approval Token):前端点击“批准执行”时,发送的不能是简单的布尔值 { approved: true },而必须携带服务端签发的包含 callIdriskHash 和过期时间的短期签名 Token。服务端校验 Token 且只允许消费一次。
  2. 服务端鉴权再核实(Re-Authorization):即使用户在界面上点了批准,服务端在真正调用底层执行器时,必须重新确认当前 Session 用户的 RBAC/ABAC 权限,确保其有权操作目标分支或生产资源。
  3. 内容安全沙箱(Sanitized Output):模型输出的 Markdown 包含的外链链接必须强制添加 rel="noopener noreferrer nofollow";代码块与富文本禁止执行内联脚本,防止 Prompt 注入诱导执行跨站攻击。

允许回退与分支纠错:Human-in-the-Loop 的闭环

信任不仅来自开始时的授权,更来自发生偏差时的补救能力:


权威参考与演进方向

在近期的技术发展中,AI 工作流编排正在全面拥抱人类在环(Human-in-the-loop)协议:

结语

Agent 时代的前端工程师,工作重心正在从“如何把文字漂亮地排版在气泡里”,转向“如何建立人机协同的契约与边界”。

来源可核实、动作可预审、成果可核验——把这三个确认点做扎实,Agent 产品才会真正脱离“玩具”的范畴,成为工程师和业务人员日常信赖的生产力搭档。


Edit page

Previous Post
我不会直接让 Claude Code 修 Bug,而是先让它还原现场
Next Post
Agent 流式输出的三种粒度:Token、Step 与全局 State