AI Agent 前端驾驶舱实战:复杂流程如何做成用户看得懂、敢操作的界面

18 阅读21分钟

本文是 AI Agent 工程实战系列第 7 篇。前面几篇讲了上下文工程、工具调用、任务状态机、长任务体验和 AI 工程底座,这一篇回到用户面前:复杂 Agent 流程跑起来之后,前端到底应该怎么承接?

很多人一提“Agent 前端”,第一反应是聊天框。

用户输入一句话,Agent 回复一段结果。

这当然是最直观的形态。

但当 Agent 真正进入业务系统,尤其是进入一个写作工作台,它就不再只是“对话”。

它会同时涉及:

正文草稿
章节计划
上下文依据
写作提案
质量扫描
修复提案
Agent 任务
步骤进度
成本与错误
定稿状态

如果这些东西都靠一个聊天窗口承接,用户很快会失去判断力:

AI 现在到底在做什么?
这次提案基于哪一版正文?
质检结果是不是旧版本?
这个修复能不能直接点?
任务失败后应该重试还是重新生成?
上下文变了,会不会影响当前提案?

所以这一篇我想讲的是:

Agent 前端驾驶舱,不是把功能堆成一个大屏,而是把复杂 Agent 流程翻译成用户能理解、敢操作、能恢复的界面。

本文继续结合我的 AI 小说创作系统源码来讲。

01. 先给结论

我现在做 Agent 前端驾驶舱,会优先考虑 6 个问题:

问题前端要给出的答案
当前写作对象是谁当前作品、章节、revision、content hash 必须明确
Agent 正在做什么任务状态、步骤进度、错误原因要可见
这次结果基于什么上下文快照、质检来源、提案来源要可追踪
用户现在能做什么按钮要根据状态启用或禁用
什么情况下只能看旧版本结果、旧章节聚合、过期提案要只读
出错后怎么恢复重试、刷新、跳转任务中心要有明确入口

对应到系统里,前端驾驶舱分成几层:

层级作用
路由层/novel/:novelId/workspace/:chapterId 明确当前章节
聚合层一次读取任务、提案、质检、计划、上下文
编辑层自动保存草稿,暴露 flush / revision / selection 能力
面板层提案、任务、质检、上下文分别承接不同 Agent 状态
操作层所有命令先保存草稿,再带 revision/hash 发起
恢复层任务中心、错误提示、重试按钮、只读保护

一句话概括:

后端负责把 Agent 跑稳,前端负责让用户看懂、敢点、点错也不脏数据。

02. 驾驶舱不是“大屏”,而是“可操作状态机”

很多系统做复杂流程前端时,容易走向“大屏化”:

左边一堆指标
中间一堆卡片
右边一堆日志
顶部一堆按钮

看起来很专业。

但用户真正需要的不是信息密度,而是下一步动作。

比如写作工作台里,作者关心的是:

我现在能不能生成提案?
这个提案能不能应用?
这个质检问题能不能修复?
这个任务能不能取消?
这个失败任务能不能继续?
上下文变了,我现在操作会不会有风险?

所以驾驶舱的核心不是展示信息,而是把后端状态机翻译成可操作状态。

在当前项目里,所有面板都围绕几个关键版本字段工作:

字段前端用途
revision当前章节正文版本
content_hash当前正文内容哈希
context_revision当前上下文关联版本
context_snapshot_id某次 AI 调用使用的冻结上下文
source_revision提案或质检来源版本
source_content_hash提案或质检来源正文

这些字段看起来很后端。

但它们直接决定前端按钮能不能点。

这就是 Agent 驾驶舱和普通管理后台的区别:

普通后台展示数据,Agent 驾驶舱要根据数据判断动作是否安全。

03. 先看整体布局:桌面端三栏,移动端降级

入口页面非常简单:

export default function WritingWorkspacePage() {
  const { isMobile } = useWorkspaceViewport();
  return isMobile
    ? <MobileWorkspacePage />
    : (
      <Suspense fallback={
        <main className="writing-workspace" aria-label="写作工作区">
          <div className="workspace-muted">加载工作台中</div>
        </main>
      }>
        <DesktopWorkspacePage />
      </Suspense>
    );
}

桌面端是主战场。

因为写作工作台需要同时处理:

左侧:章节导航、规划与上下文
中间:正文编辑器、章节脉络
右侧:提案、任务、质检、协作总览、技术详情

移动端则不硬塞完整能力:

export function MobileWorkspaceBody({
  chapter,
  taskState,
  selectedTaskId,
  selectedIssueId,
  onReturnToNovel,
  context,
  isContextCurrent = false,
  isContextLoading = false,
}: {
  chapter: WorkspaceChapter;
  taskState: AgentTasksController;
  selectedTaskId?: number;
  selectedIssueId?: string;
  onReturnToNovel: () => void;
  context?: WritingWorkbenchPanelState;
  isContextCurrent?: boolean;
  isContextLoading?: boolean;
}) {
  const chapterTasks = taskState.tasks.filter(
    (task) => task.origin_chapter_id === chapter.id
      || task.proposal_chapter_id === chapter.id,
  );
  return (
    <section className="workspace-mobile-body" aria-label="移动端章节正文">
      <header className="workspace-editor-head">...</header>
      <article className="workspace-mobile-content">
        {chapter.content || '本章暂无正文。'}
      </article>
      <section className="workspace-mobile-context" aria-label="移动端上下文概览">
        {isContextLoading
          ? <p className="workspace-panel-copy">上下文加载中</p>
          : <ContextHubSummary context={context} isCurrent={isContextCurrent} />}
        <p className="workspace-panel-copy">上下文仅可在桌面端管理。</p>
      </section>
      <MobileTaskPanel
        tasks={chapterTasks}
        selectedTaskId={selectedTaskId}
        cancel={taskState.cancel}
        retry={(task) => taskState.retry(task, chapter.revision)}
        isCancelling={taskState.isCancelling}
        isRetrying={taskState.isRetrying}
      />
      {selectedIssueId && (
        <div className="workspace-mobile-quality-hint">
          请在桌面端打开质量中心
        </div>
      )}
    </section>
  );
}

这里的取舍很明确:

设计重点
桌面端完整创作、上下文管理、提案审阅、质检修复
移动端阅读正文、查看上下文摘要、查看/处理任务状态

我没有把上下文管理、质检定位、提案应用等复杂动作硬搬到移动端。

因为这些动作需要编辑器、选区、版本校验和大量上下文判断。

移动端保留观察和轻操作,桌面端承接完整驾驶舱。

这也是产品化里的一个重要判断:

不是所有 Agent 能力都要在所有端完整开放。

04. 聚合接口:复杂面板不能各查各的

如果每个面板都自己请求:

任务列表接口
提案列表接口
质检结果接口
计划接口
上下文接口
技能接口
章节接口

会带来几个问题:

问题后果
请求太多首屏慢,状态闪烁
版本不一致面板 A 是新章节,面板 B 还显示旧章节
错误互相污染某个接口失败导致整个页面逻辑混乱
刷新困难Agent 状态变化后不知道该刷新哪些面板

所以系统里有一个只读聚合接口:

@router.get("/{chapter_id}", response_model=WritingWorkbenchResponse)
async def get_writing_workbench(
    novel_id: int,
    chapter_id: int,
    cursor: int | None = Query(default=None, ge=1),
    limit: int = Query(default=20, ge=1, le=100),
    session_factory=Depends(get_session_factory),
):
    async with session_factory() as db:
        chapter = await db.scalar(
            select(Chapter).where(
                Chapter.id == chapter_id,
                Chapter.novel_id == novel_id,
            )
        )
    if chapter is None:
        raise HTTPException(status_code=404, detail="章节不存在")

    context_panel = await _run_panel(
        session_factory,
        _load_context,
        "context_unavailable",
        "无法加载上下文",
        chapter,
    )
    context_revision = (
        context_panel["data"].get("context_revision", chapter.context_revision)
        if isinstance(context_panel.get("data"), dict)
        else chapter.context_revision
    )
    return {
        "chapter": {
            "id": chapter.id,
            "revision": chapter.revision,
            "context_revision": context_revision,
            "content_hash": content_hash(chapter.content),
        },
        "tasks": await _run_panel(session_factory, _load_tasks, "tasks_unavailable", "无法加载任务", chapter, cursor, limit),
        "proposals": await _run_panel(session_factory, _load_proposals, "proposals_unavailable", "无法加载提案", chapter),
        "quality": await _run_panel(session_factory, _load_quality, "quality_unavailable", "无法加载质检", chapter),
        "plans": await _run_panel(session_factory, _load_plans, "plans_unavailable", "无法加载计划", chapter),
        "context": context_panel,
        "skills": await _run_panel(session_factory, _load_skills, "skills_unavailable", "无法加载技能", chapter),
    }

注意这里每个面板都走 _run_panel()

async def _run_panel(
    session_factory: Callable[[], Any],
    loader: PanelLoader,
    code: str,
    message: str,
    *args: Any,
) -> dict[str, Any]:
    try:
        async with session_factory() as db:
            return _panel(await loader(db, *args))
    except ContextRefreshRequired:
        return _panel(code="context_refresh_required", message="章节上下文已变化,请刷新")
    except Exception:
        logger.warning("writing_workbench 面板加载失败 code=%s", code, exc_info=True)
        return _panel(code=code, message=message)

这意味着:

任务加载失败,不影响正文和提案显示
上下文加载失败,不影响质检结果显示
质检加载失败,不影响章节编辑

面板级错误是驾驶舱很重要的设计。

复杂 Agent 系统里,局部失败应该局部提示,而不是整页崩掉。

05. 前端聚合模型:给每个面板加 canAct

后端返回的是数据。

前端还要把它转换成可操作状态。

useWritingWorkbench() 做了这层事:

function isStale(data: unknown): boolean {
  if (Array.isArray(data)) return data.some((item) => isStale(item));
  if (!data || typeof data !== 'object') return false;
  const source = data as Record<string, unknown>;
  if (source.stale === true) return true;
  return Array.isArray(source.items) && source.items.some((item) => isStale(item));
}

function withState(panel: WritingWorkbenchPanel) {
  const hubState = !Array.isArray(panel.data)
    && panel.data
    && typeof panel.data === 'object'
    ? (panel.data as Record<string, unknown>).state
    : undefined;
  const stale = Array.isArray(panel.data)
    ? false
    : isStale(panel.data) || hubState === 'body_stale';
  return {
    ...panel,
    isStale: stale,
    canAct: !panel.error && !stale,
  };
}

function panelsFor(workbench: WritingWorkbenchAggregate | undefined): WritingWorkbenchPanels {
  const empty = { data: null, error: null };
  return {
    tasks: withState(workbench?.panels.tasks ?? empty),
    proposals: withState(workbench?.panels.proposals ?? empty),
    quality: withState(workbench?.panels.quality ?? empty),
    plans: withState(workbench?.panels.plans ?? empty),
    context: withState(workbench?.panels.context ?? empty),
    skills: withState(workbench?.panels.skills ?? empty),
  };
}

这里有两个非常关键的概念:

状态含义
isStale数据对应旧版本,不能直接操作
canAct没有错误且不过期,可以执行命令

这让组件不用反复理解后端细节。

每个面板只要知道:

当前数据是不是旧的?
当前面板能不能操作?

这就是把复杂状态收敛成 UI 可用状态。

06. 防止旧章节面板误操作:isCurrentChapter

Agent 驾驶舱最怕一种问题:

用户从 A 章切到 B 章
B 章正文已经显示
A 章聚合数据还没刷新完
右侧仍然露出 A 章提案和操作按钮
用户误点应用

所以 useWritingWorkbench() 里有一个 isCurrentChapter

export function useWritingWorkbench(
  novelId: number,
  chapterId: number,
  revision?: number,
  contentHash?: string,
) {
  const query = useQuery({
    queryKey: writingWorkbenchQueryKeys.workbench(novelId, chapterId),
    queryFn: async ({ signal }) =>
      parseWritingWorkbenchResponse(await workbenchApi.get(novelId, chapterId, signal)),
    enabled: Number.isInteger(novelId)
      && novelId > 0
      && Number.isInteger(chapterId)
      && chapterId > 0,
    placeholderData: keepPreviousData,
  });

  return {
    chapter: query.data?.chapter,
    aggregateChapterId: query.data?.chapter.id,
    panels: panelsFor(query.data),
    isPlaceholderData: query.isPlaceholderData,
    isCurrentChapter: !query.isPlaceholderData
      && query.data?.chapter.id === chapterId
      && (revision === undefined || query.data?.chapter.revision === revision)
      && (contentHash === undefined || query.data?.chapter.content_hash === contentHash),
  };
}

页面层再组合成:

const hasCurrentWorkbench = Boolean(
  activeChapter
    && workbench.isCurrentChapter
    && workbench.aggregateChapterId === activeChapter.id,
);

然后传给提案和质检:

<ProposalPanel
  novelId={novelId}
  chapterId={activeChapter.id}
  revision={activeChapter.revision}
  readOnly={!hasCurrentWorkbench}
  staleById={proposalStaleById}
/>

<QualityIssuePanel
  novelId={novelId}
  chapterId={activeChapter.id}
  chapterNumber={activeChapter.chapter_number}
  revision={activeChapter.revision}
  contentHash={activeChapter.content_hash}
  aggregateQuality={workbench.panels.quality}
  readOnly={!hasCurrentWorkbench}
/>

测试里也专门验证了这个场景:

it('never renders A aggregate panels or actions while B aggregate is pending', async () => {
  fireEvent.click(screen.getByRole('button', { name: '切换B章' }));

  await waitFor(() =>
    expect(screen.getByRole('textbox', { name: '正文编辑器' }))
      .toHaveValue('B 正文'),
  );
  expect(screen.queryByText('A计划')).not.toBeInTheDocument();
  expect(screen.queryByText('A任务')).not.toBeInTheDocument();
  expect(screen.queryByText('88 分')).not.toBeInTheDocument();
  expect(screen.getByLabelText('B提案操作')).toBeDisabled();
  expect(screen.getByLabelText('B质检操作')).toBeDisabled();

  await act(async () => { resolveB?.(aggregate(13, 'B')); });
  await waitFor(() =>
    expect(screen.getByLabelText('B提案操作')).toBeEnabled(),
  );
});

这个测试很典型。

它测的不是样式,而是驾驶舱的安全边界:

当前章节聚合数据没确认之前,操作按钮必须先禁用。

07. 编辑器不是 textarea:它要给 Agent 命令提供版本能力

从 UI 看,中间只是一个正文编辑器。

但在 Agent 驾驶舱里,编辑器不是普通输入框。

它要给右侧所有 AI 命令提供几个能力:

保存当前草稿
返回当前 revision / content_hash
定位质检问题范围
读取当前选区

DesktopWorkspaceBody 暴露了这些动作:

export interface WorkspaceDraftActions {
  flush: () => Promise<void>;
  getVersion: () => { expectedRevision: number; contentHash: string };
  locateRange: (start: number, end: number) => void;
  getSelection: () => { start: number; end: number; content: string } | undefined;
}

实现里把编辑器能力注册给页面:

export function DesktopWorkspaceBody({
  chapter,
  onDraftActions,
}: {
  chapter: WorkspaceChapter;
  onDraftActions?: (actions: WorkspaceDraftActions | undefined) => void;
}) {
  const draft = useChapterDraft(chapter);
  const editorRef = useRef<HTMLTextAreaElement>(null);

  const locateRange = useCallback((start: number, end: number) => {
    const editor = editorRef.current;
    if (!editor) return;
    const from = Math.max(0, Math.min(start, editor.value.length));
    const to = Math.max(from, Math.min(end, editor.value.length));
    editor.focus();
    editor.setSelectionRange(from, to);
  }, []);

  const getSelection = useCallback(() => {
    const editor = editorRef.current;
    if (!editor) return undefined;
    return {
      start: editor.selectionStart,
      end: editor.selectionEnd,
      content: draft.content,
    };
  }, [draft.content]);

  useEffect(() => {
    onDraftActions?.({
      flush: draft.flush,
      getVersion: draft.getVersion,
      locateRange,
      getSelection,
    });
    return () => onDraftActions?.(undefined);
  }, [draft.flush, draft.getVersion, getSelection, locateRange, onDraftActions]);
}

页面层会在发起命令前调用这些能力:

const proposalRevision = useCallback(async (fallbackRevision: number) => {
  if (!draftActions.current) return fallbackRevision;
  await draftActions.current.flush();
  return draftActions.current.getVersion().expectedRevision;
}, []);

const qualityCommandVersion = useCallback(
  async (fallback: { revision: number; contentHash: string }) => {
    if (!draftActions.current) return fallback;
    await draftActions.current.flush();
    const version = draftActions.current.getVersion();
    return {
      revision: version.expectedRevision,
      contentHash: version.contentHash,
    };
  },
  [],
);

这一步非常关键。

否则用户刚改完正文,右侧点“生成提案”或“修复质检问题”,AI 命令可能基于旧正文执行。

所以驾驶舱里的按钮不是直接发请求。

它们要先:

保存草稿
读取最新 revision/hash
确认来源仍然匹配
再发起 Agent 命令

08. 自动保存:Agent 操作前必须先收口本地草稿

useChapterDraft() 是工作台的底层编辑状态。

它处理自动保存、本地草稿、冲突恢复和版本推进。

核心保存逻辑里,保存请求会带上 expected_revision

const saveSnapshot = useCallback(async (snapshot: {
  generation: number;
  content: string;
  revision: number;
}) => {
  setSaveState('saving');
  try {
    const sequence = await nextSequence(chapter.novel_id, chapter.id);
    const mutationId = crypto.randomUUID();
    const response = await workspaceApi.saveChapter(chapter.novel_id, chapter.id, {
      content: snapshot.content,
      expected_revision: current.current.revision,
      save_kind: 'autosave',
      client_id: clientId(),
      client_sequence: sequence,
      mutation_id: mutationId,
    });

    void advanceDraftBase(
      chapter.novel_id,
      chapter.id,
      response.revision,
    ).catch(() => undefined);

    if (snapshot.generation === generation.current) {
      current.current = {
        content: response.content,
        revision: response.revision,
        contentHash: response.content_hash,
      };
      setContentState(response.content);
      setSaveState('clean');
    } else {
      current.current.revision = response.revision;
      current.current.contentHash = response.content_hash;
      setSaveState('dirty');
    }
  } catch {
    setSaveState('error');
    throw new Error('chapter_autosave_failed');
  }
}, [chapter.id, chapter.novel_id, queryClient]);

这里有两个容易忽略的细节。

第一,保存成功后推进本地草稿基准:

advanceDraftBase(...)

避免用户关闭页面后,本地草稿还停留在旧 revision,被误判为跨端冲突。

第二,处理“保存请求在途期间用户继续输入”的情况:

如果保存的是最新 generation:编辑器变 clean
如果保存的是过期 generation:只推进服务器基准,编辑器仍然 dirty

这对 Agent 按钮很重要。

因为按钮发起前的 flush() 要拿到真实的最新基准。

前端驾驶舱要保证:

用户看到的是正在写的内容,Agent 命令使用的是已经确认过的版本。

09. 提案面板:AI 结果不能直接写正文

这个项目有一条硬约束:

AI 永远不直接改写章节正文,必须先生成 WritingProposal,经作者审阅后再应用。

所以前端右侧的核心不是“AI 写入”,而是“写作提案”。

提案面板的操作很直观:

<Button
  variant="primary"
  size="sm"
  disabled={readOnly || proposals.isCreating}
  onClick={createProposal}
>
  生成提案
</Button>

但真正的创建逻辑会先准备版本:

const preparedRevision = async () =>
  prepareCommand ? await prepareCommand() : revision;

const createProposal = () => {
  lastAttempt.current = createProposal;
  void (async () => {
    try {
      setCommandError(undefined);
      await proposals.create(
        prepareSelection
          ? await prepareSelection()
          : {
              kind: 'chapter_generate',
              expected_revision: await preparedRevision(),
              metadata: {},
            },
      );
    } catch {
      setCommandError('草稿没能保存成功,操作没完成。');
    }
  })();
};

应用、撤销、重试也一样:

{proposal.status === 'ready' && !isBlocked && (
  <Button
    disabled={readOnly || isStale || proposals.isApplying}
    onClick={() =>
      runPreparedCommand((latestRevision) =>
        proposals.apply(proposal.id, latestRevision),
      )
    }
  >
    应用提案
  </Button>
)}

{proposal.status === 'applied' && (
  <Button
    disabled={readOnly || isStale || proposals.isUndoing}
    onClick={() =>
      runPreparedCommand((latestRevision) =>
        proposals.undo(proposal.id, latestRevision),
      )
    }
  >
    撤销
  </Button>
)}

提案面板里还有几个用户体验细节。

状态前端处理
ready可以应用或丢弃
applied可以撤销
pending / generating可以取消
failed / cancelled / stale可以重试
stale 或聚合过期只读
连续性阻断展示阻断原因
缺少章节计划告诉用户先完成计划

这段提示很典型:

{isContextBlocked && (
  <span className="workspace-proposal-blocked" role="status">
    本章还没有已批准的章节计划,AI 缺少写作依据,不会开始生成。
    请先完成上方「① 计划」步骤(生成并批准本章计划),
    然后取消此提案并重新生成。
  </span>
)}

它不是单纯报错。

它告诉用户:

为什么阻断
系统不会做什么
下一步去哪做
做完之后怎么恢复

这就是 Agent 驾驶舱需要的“可解释操作”。

10. 选区改写:让 AI 操作贴近作者动作

提案不一定都是整章生成。

如果用户在编辑器中选中一段文字,可以发起选区改写。

页面层会读取当前选区并计算 selection hash:

const prepareSelectionProposal = useCallback(
  async (fallbackRevision: number): Promise<CreateInput> => {
    const actions = draftActions.current;
    const selection = actions?.getSelection();
    if (!actions || !selection || selection.start === selection.end) {
      return {
        kind: 'chapter_generate',
        expected_revision: fallbackRevision,
        metadata: {},
      };
    }
    const selectionHash = await contentHash(
      sliceUtf16(selection.content, selection.start, selection.end),
    );
    await actions.flush();
    return {
      kind: 'selection_rewrite',
      expected_revision: actions.getVersion().expectedRevision,
      metadata: {},
      start_offset: selection.start,
      end_offset: selection.end,
      selection_hash: selectionHash,
    };
  },
  [],
);

这里的重点不是“支持选区”。

而是选区命令也有来源校验:

start_offset
end_offset
selection_hash
expected_revision

这样后端可以判断:

这段文字还是不是用户刚才选中的那段?
正文版本有没有变化?
是否还能安全生成局部改写提案?

Agent 前端的好体验,往往来自这种细边界。

用户觉得只是“选中一段,让 AI 改一下”。

系统背后必须保证:改的是同一段,基于的是同一版正文。

11. 质检面板:旧结果必须只读

质检结果最容易被误用。

因为用户可能先执行质检,然后继续编辑正文,再回来点“修复”。

如果系统不判断版本,就会基于旧问题创建修复提案。

所以 QualityIssuePanel 会判断结果是否属于当前正文:

const result = useMemo(
  () =>
    aggregateResult(aggregateQuality)
      ?? quality.result
      ?? (quality.issues.length > 0
        ? {
            issues: quality.issues,
            source_content_hash: contentHash,
            source_revision: revision,
            quality_cache_id: 0,
            context_snapshot_id: 0,
          }
        : undefined),
  [aggregateQuality, contentHash, quality.issues, quality.result, revision],
);

const isCurrentResult = Boolean(
  result
    && result.source_content_hash === contentHash
    && result.source_revision === revision,
);

渲染时,如果结果不是当前版本,就提示旧版本:

{(quality.isStale || (Boolean(result) && !isCurrentResult)) && (
  <div className="workspace-quality-stale">结果对应旧版本正文</div>
)}

每个问题也会单独判断能不能操作:

const issueStale = !isCurrentResult
  || issue.source_kind !== 'quality_scan'
  || !result
  || result.quality_cache_id <= 0
  || result.context_snapshot_id <= 0;

<Button
  aria-label={`创建修复提案 ${issue.id}`}
  disabled={readOnly || issueStale || proposals.isCreatingQualityFix}
  onClick={() => createFix(issue)}
>
  修复
</Button>

真正创建修复前,还会再次保存草稿并复核版本:

const createFix = (issue: QualityIssue) => {
  void (async () => {
    try {
      setCommandError(undefined);
      const latest = prepareCommand
        ? await prepareCommand()
        : { revision, contentHash };
      if (
        !result
        || result.source_revision !== latest.revision
        || result.source_content_hash !== latest.contentHash
      ) {
        setCommandError('质检结果已过期,请重新执行质检');
        return;
      }
      if (
        result.quality_cache_id <= 0
        || result.context_snapshot_id <= 0
        || issue.source_kind !== 'quality_scan'
      ) {
        setCommandError('质检数据不完整,请重新执行一次质检。');
        return;
      }
      await proposals.createQualityFix(chapterNumber, result, issue);
    } catch {
      setCommandError('草稿没能保存成功,修复提案没建成。');
    }
  })();
};

测试也覆盖了这个边界:

it('does not create a quality-fix proposal when flushing advances the chapter beyond the scanned provenance', async () => {
  const prepareCommand = vi.fn().mockResolvedValue({
    revision: 5,
    contentHash: 'b'.repeat(64),
  });

  fireEvent.click(screen.getByRole('button', {
    name: '创建修复提案 fresh-issue',
  }));

  await waitFor(() => expect(prepareCommand).toHaveBeenCalledTimes(1));
  expect(proposals.createQualityFix).not.toHaveBeenCalled();
  expect(await screen.findByText('质检结果已过期,请重新执行质检'))
    .toBeInTheDocument();
});

这就是前端驾驶舱的底线:

旧版本结果可以展示,但不能继续执行写入类动作。

12. 质检定位:让问题回到正文里

质检面板不只是列问题。

它还要把问题定位回正文。

DesktopWorkspaceBody 暴露了 locateRange(),质检面板调用它:

const locateQualityIssue = useCallback((start: number, end: number) => {
  draftActions.current?.locateRange(start, end);
}, []);

<QualityIssuePanel
  onLocate={locateQualityIssue}
/>

面板内部:

{!issueStale
  && issue.locatable
  && issue.start_offset !== null
  && issue.end_offset !== null
  && onLocate
  && (
    <Button
      aria-label={`定位问题 ${issue.id}`}
      onClick={() => onLocate(issue.start_offset!, issue.end_offset!)}
    >
      定位
    </Button>
  )}

测试验证的是编辑器真的被选中:

it('selects the requested quality issue range in the editor', async () => {
  let actions;
  render(<DesktopWorkspaceBody chapter={chapter} onDraftActions={(next) => {
    actions = next;
  }} />);

  await waitFor(() => expect(actions).toBeDefined());
  actions?.locateRange(1, 3);

  const editor = screen.getByRole('textbox', { name: '正文编辑器' });
  expect(document.activeElement).toBe(editor);
  expect([editor.selectionStart, editor.selectionEnd]).toEqual([1, 3]);
});

这也是一个体验上的分水岭。

只告诉用户“节奏偏慢”,用户还要自己找。

能定位到正文范围,用户才会觉得 AI 质检是工作流的一部分,而不是旁边的批注噪音。

13. 任务面板:把后端状态机翻译成动作

第 4、5 篇讲过 AgentTask 和长任务体验。

前端驾驶舱里,任务面板承担的是翻译工作。

状态文案:

const STATUS_TEXT: Record<AgentTaskStatus, string> = {
  pending: '等待中',
  pending_review: '待审核',
  running: '执行中',
  completed: '已完成',
  failed: '失败',
  cancelled: '已取消',
  interrupted: '已中断',
};

动作判断:

function isActive(status: AgentTaskStatus) {
  return status === 'pending' || status === 'running';
}

function canRetry(status: AgentTaskStatus) {
  return status === 'pending_review'
    || status === 'failed'
    || status === 'cancelled'
    || status === 'interrupted';
}

渲染:

{isActive(task.status) && (
  <Button
    aria-label={`取消任务 #${task.id}`}
    disabled={isCancelling}
    onClick={() => void cancel(task.id)}
  >
    <XCircle size={14} />
  </Button>
)}

{canRetry(task.status) && (
  <Button
    aria-label={`重试任务 #${task.id}`}
    disabled={isRetrying}
    onClick={() => void retry(task)}
  >
    <RotateCcw size={14} />
  </Button>
)}

还会展示步骤和成本:

function TaskSteps({ task }: { task: AgentTask }) {
  const isActive = task.status === 'pending' || task.status === 'running';
  const { data: steps } = useAgentSteps(task.id, isActive);
  const showCost = !isActive && task.status !== 'cancelled';
  const { data: cost } = useQuery({
    queryKey: ['workspace', 'agent-task', task.id, 'cost'],
    queryFn: async () => (await agentApi.getTaskCost(task.id)).data,
    enabled: showCost,
  });

  return (
    <div className="workspace-task-detail">
      {steps && <AgentStepList steps={steps} />}
      {cost && (
        <p className="workspace-task-cost">
          共 {cost.total_calls} 次 AI 调用 ·
          {(cost.total_prompt_tokens + cost.total_completion_tokens).toLocaleString()} tokens ·
          ¥{cost.total_cost_rmb.toFixed(4)}
        </p>
      )}
    </div>
  );
}

这和第 6 篇的成本意识接上了。

用户不是只看到“AI 完成了”。

他还能看到:

跑了几步
卡在哪一步
失败原因是什么
用了多少 token
成本是多少

这是让 Agent 从黑箱变成业务协作者的关键。

14. AgentStepList:进度必须来自真实步骤

任务步骤列表很简单,但意义很大:

export function AgentStepList({ steps }: { steps: AgentStep[] }) {
  if (!steps.length) return null;
  const sorted = [...steps].sort((a, b) => a.sort_order - b.sort_order);
  return (
    <ol className="workspace-step-list" aria-label="任务分步进度">
      {sorted.map((step) => {
        const duration = formatDuration(step.started_at, step.finished_at);
        return (
          <li key={step.id} className="workspace-step-item">
            <span className={`workspace-step-dot ${STEP_STATUS_DOT[step.status]}`} />
            <div className="workspace-step-body">
              <div className="workspace-step-head">
                <span className="workspace-step-title">
                  {step.title || step.step_key}
                </span>
                <span className="workspace-step-status">
                  {STEP_STATUS_TEXT[step.status]}
                </span>
                {duration && <span className="workspace-step-duration">{duration}</span>}
              </div>
              {step.message && <p className="workspace-step-message">{step.message}</p>}
              {step.error && step.status === 'failed' && (
                <p className="workspace-step-error">{step.error}</p>
              )}
            </div>
          </li>
        );
      })}
    </ol>
  );
}

我不建议前端自己模拟 Agent 进度。

比如:

10% 读取上下文
30% 生成中
70% 审核中
100% 完成

如果这些进度不来自后端真实状态,用户迟早会发现它只是“安慰条”。

当前系统里,进度来自 AgentStep

所以用户看到的是实际执行过程:

准备上下文
生成草稿
连续性审核
创建提案
等待审核
提取记忆

这就是驾驶舱的可信度来源。

15. 上下文抽屉:把 Prompt 依据变成可审计对象

Agent 写作最难解释的一点是:

它为什么这么写?
它参考了哪些设定?
它是不是用了旧上下文?

所以工作台左侧有一个“规划与上下文”抽屉。

默认只展示摘要:

export function ContextHubSummary({
  context,
  isCurrent,
}: {
  context: WritingWorkbenchPanelState;
  isCurrent: boolean;
}) {
  const hub = hubData(context.data);
  if (!isCurrent) {
    return (
      <section aria-label="上下文中枢">
        <p className="workspace-panel-copy">正在切换章节上下文</p>
      </section>
    );
  }
  return (
    <section className="workspace-context-hub-summary" aria-label="上下文中枢">
      <div className="workspace-section-heading">
        <span><Brackets size={15} /> 上下文中枢</span>
        {hub && (
          <small className={`workspace-context-state is-${hub.state}`}>
            {stateLabel(hub.state)}
          </small>
        )}
      </div>
      {hub && (
        <div className="workspace-context-meta">
          {hub.latest_snapshot
            ? (
              <>
                <span>快照 #{hub.latest_snapshot.id} · {hub.latest_snapshot.scope}</span>
                <code title={hub.latest_snapshot.context_hash}>
                  {hub.latest_snapshot.context_hash.slice(0, 8)}
                </code>
              </>
            )
            : <span>尚未冻结章节快照</span>}
          <span>
            任务 {hub.usages.tasks} · 提案 {hub.usages.proposals} ·
            质检 {hub.usages.quality} · 计划 {hub.usages.plans}
          </span>
          <span>关联 {hub.links.length} 条</span>
        </div>
      )}
    </section>
  );
}

展开后才加载管理和审计:

{isOpen && (
  <div id={contentId}>
    {!isCurrent ? (
      <p className="workspace-panel-copy">正在加载当前章节工作台数据</p>
    ) : (
      <>
        {contextStaleNotice(context.isStale)}
        <ContextHubPanel
          novelId={novelId as number}
          chapterId={chapterId as number}
          chapterNumber={chapterNumber as number}
          context={context}
          isCurrent={isCurrent}
        />
        <ContextAuditPanel
          timeline={audit.data}
          loading={audit.isLoading}
          error={audit.error}
          onRecreate={canRecreate ? recreate : undefined}
          recreating={recreating}
          recreateMessage={recreateMessage}
        />
      </>
    )}
  </div>
)}

这里有一个产品取舍:

默认展示摘要,避免压迫写作区域
需要时展开详情,管理关联和审计快照
切换章节时不展示旧章节上下文
正文变更时提示“刷新后写作会使用新上下文”

测试也验证了切换章节时不泄露旧上下文:

it('does not expose a previous chapter context while the drawer switches aggregates', () => {
  render(<PlanningDrawer initialOpen isCurrent={false} context={oldContext} />);

  expect(screen.getByText('正在切换章节上下文')).toBeInTheDocument();
  expect(screen.queryByText('快照 #12 · chapter')).not.toBeInTheDocument();
  expect(screen.queryByText('关联 1 条')).not.toBeInTheDocument();
  expect(screen.queryByText('旧章节上下文错误')).not.toBeInTheDocument();
});

上下文抽屉的本质不是“Prompt 预览”。

它是在告诉用户:

当前 Agent 的依据是否当前、是否可追踪、是否需要刷新。

16. 上下文管理:复杂命令要有 pending 状态和取消

上下文关联管理里,一个命令可能会触发后端回执、版本确认、刷新。

所以前端不能只是点按钮后等结果。

它要跟踪每个 pending operation:

const [pendingOperations, setPendingOperations] =
  useState<Record<string, string>>({});
const operationEpoch = useRef(0);

const runCommand = async (
  key: string,
  label: string,
  execute: (epoch: number) => Promise<unknown>,
) => {
  const epoch = operationEpoch.current;
  setCommandError(undefined);
  setPendingOperations((current) => ({
    ...current,
    [key]: label,
  }));
  try {
    await execute(epoch);
  } catch (error) {
    if (operationEpoch.current !== epoch || commands.isCommandAborted(error)) return;
    if (error instanceof ContextLinkConflictError) {
      setCommandError('章节上下文已变化,已刷新数据,请重新确认');
    } else {
      setCommandError('上下文命令未完成,请稍后重试');
    }
  } finally {
    if (operationEpoch.current === epoch) {
      setPendingOperations((current) => {
        const next = { ...current };
        delete next[key];
        return next;
      });
    }
  }
};

如果有命令在执行,展示状态:

{active && (
  <p className="workspace-context-progress" role="status">
    <LoaderCircle size={13} />
    {Object.values(pendingOperations).join(';')}:正在等待命令回执
  </p>
)}

也提供取消:

{active && (
  <Button
    aria-label="取消上下文命令"
    onClick={() => {
      operationEpoch.current += 1;
      commands.cancel();
      setPendingOperations({});
    }}
  >
    <X size={13} /> 取消全部
  </Button>
)}

这里的 operationEpoch 很实用。

它解决的是:

用户切换章节
旧命令返回
旧命令不应该再污染当前面板状态

这类细节,在复杂 Agent 驾驶舱里非常常见。

17. 章节脉络:把流程变成用户路径

如果用户刚进入工作台,只看到一堆按钮,会不知道从哪里开始。

所以中间编辑区上方有一个章节脉络:

export function ChapterPulseSection({
  novelId,
  volumeNumber,
  chapterNumber,
  chapterHasContent,
  chapterFinalized,
  proposalDone,
  qualityDone,
  onPlanApproved,
}: {
  novelId: number;
  volumeNumber: number;
  chapterNumber: number;
  chapterHasContent: boolean;
  chapterFinalized: boolean;
  proposalDone: boolean;
  qualityDone: boolean;
  onPlanApproved?: () => void;
}) {
  const storyPlanning = useChapterStoryPlan(novelId, chapterNumber);
  const planDone = Boolean(storyPlanning.activePlan);
  const steps = computePulseSteps({
    planDone,
    draftDone: chapterHasContent,
    proposalDone,
    qualityDone,
    finalized: chapterFinalized,
  });
  const currentKey = steps.find((step) => step.state === 'current')?.key;
  const planOpen = openOverride ?? (currentKey === 'plan');
  return (
    <div className="chapter-pulse-section">
      <ChapterPulse steps={steps} onSelect={handleSelect} />
      {planOpen && !planDone && (
        <ChapterPlanStepCard
          novelId={novelId}
          volumeNumber={volumeNumber}
          chapterNumber={chapterNumber}
          onPlanApproved={() => {
            setOpenOverride(false);
            onPlanApproved?.();
          }}
        />
      )}
    </div>
  );
}

这条脉络把复杂流程变成了用户路径:

计划
正文
提案
质检
定稿

Agent 系统不是让用户一次点一堆 AI 能力。

而是告诉他:

先把计划准备好
再写正文
再让 AI 生成提案
再质检
最后定稿

这也是“架构师带团队推进 Agent 产品”时很重要的一点:

前端要把系统能力组织成业务路径,而不是把 API 能力裸露给用户。

18. 任务中心:工作台之外还要有全局视角

工作台里的任务面板只解决当前章节。

但 Agent 任务可能从多个入口发起:

写作工作区
质量中心
自动写作
批量修复
上下文命令
提案重试
记忆提取

所以系统还有独立任务中心:

const FILTERS = [
  { key: 'all', label: '全部' },
  { key: 'active', label: '进行中' },
  { key: 'failed', label: '需处理' },
] as const;

加载任务时支持过滤和 deep link:

const requestedTaskId =
  Number(new URLSearchParams(location.search).get('task')) || null;

const loadTasks = useCallback(async () => {
  if (!nid) return;
  setError('');
  try {
    const params = filter === 'active'
      ? { novel_id: nid, active: true, limit: 20 }
      : { novel_id: nid, limit: 20 };
    const { data } = await agentApi.listTasks(params);
    const visible = filter === 'failed'
      ? data.filter(
          task => task.status === 'failed' || task.status === 'interrupted',
        )
      : data;
    setTasks(visible);
    setSelectedId((current) => {
      if (requestedTaskId && visible.some((task) => task.id === requestedTaskId)) {
        return requestedTaskId;
      }
      return current && visible.some(task => task.id === current)
        ? current
        : visible[0]?.id || null;
    });
  } catch (err) {
    setError(getApiErrorMessage(err, 'Agent 任务读取失败'));
  } finally {
    setLoading(false);
  }
}, [filter, nid, requestedTaskId]);

任务详情里还可以跳回工作台:

{workspaceChapterId && selectedTask && (
  <Link
    to={`/novel/${nid}/workspace/${workspaceChapterId}?panel=tasks&task=${selectedTask.id}`}
  >
    在工作台中打开
  </Link>
)}

这形成了两个视角:

视角解决什么问题
工作台任务面板当前章节正在发生什么
Agent 任务中心全作品所有 Agent 任务如何排查和恢复

复杂 Agent 产品不能只靠当前页面。

一定要有全局任务中心。

否则用户从质量中心发起的修复、从工作台发起的提案、从定稿触发的记忆提取,会散落在各处。

19. 协作总览:不要让用户在多个面板之间猜状态

右侧还有一个轻量的协作总览:

export function CollaborationPanel({
  tasks,
  proposals,
  quality,
  isCurrent = true,
}: {
  tasks: WritingWorkbenchPanelState;
  proposals: WritingWorkbenchPanelState;
  quality: WritingWorkbenchPanelState;
  isCurrent?: boolean;
}) {
  if (!isCurrent) {
    return (
      <section className="workspace-collaboration" aria-label="协作面板">
        <p className="workspace-panel-copy">正在加载当前章节工作台数据</p>
      </section>
    );
  }
  const taskItems = items(tasks).filter((task) =>
    ['pending', 'running'].includes(String(record(task).status)),
  );
  const proposalItems = items(proposals).filter((proposal) =>
    ['ready', 'pending_review'].includes(String(record(proposal).status)),
  );
  const qualityResult = record(record(quality.data).result);

  return (
    <section className="workspace-collaboration" aria-label="协作面板">
      <div className="workspace-collaboration-row">
        <strong>进行中任务</strong><span>{taskItems.length}</span>
      </div>
      <div className="workspace-collaboration-row">
        <strong>待审核提案</strong><span>{proposalItems.length}</span>
      </div>
      <div className="workspace-collaboration-row">
        <strong>质量</strong>
        <span>
          {qualityResult.overall_score === undefined
            ? '待检查'
            : `${String(qualityResult.overall_score)} 分`}
        </span>
      </div>
    </section>
  );
}

它不是新增功能。

它是把分散面板状态收成一句话:

有几个任务还在跑
有几个提案等我看
质量有没有检查

这类总览对 Agent 系统很有价值。

因为 Agent 会并发、会排队、会生成待审内容。

用户不能每次都去各个面板里找。

20. 可以直接拿走的检查表

如果你也在做 Agent 前端,可以用这张表自查。

检查项你需要确认的问题
是否明确当前业务对象页面是否绑定作品、章节、revision、hash?
是否有聚合读模型任务、提案、质检、上下文是否能在一个工作台契约里读取?
面板是否独立失败某个面板错误是否会拖垮整页?
是否区分可看和可操作旧版本数据是否只读?
命令前是否保存草稿生成、应用、修复、定稿前是否先 flush?
是否带版本参数写入类命令是否带 expected_revision / content_hash
是否防旧章节误操作章节切换时旧聚合数据是否禁用操作?
Agent 任务是否可见任务状态、步骤、错误、成本是否展示?
是否有全局任务中心跨入口任务是否能统一查找、过滤、恢复?
移动端是否合理降级移动端是否避免开放高风险复杂操作?
错误是否告诉下一步用户是否知道要刷新、重试、去计划页,还是只读?

这张表背后的原则是:

Agent 前端不是展示 AI,而是管理 AI 参与业务后的状态和风险。

21. 总结

这一篇讲的是 Agent 前端驾驶舱。

我的核心观点是:

当 Agent 进入真实业务,前端不应该只是聊天框,也不应该只是功能大屏,而应该是一套可操作状态机。

在当前 AI 小说创作系统里,这套驾驶舱主要由这些部分组成:

写作工作区路由
工作台聚合接口
章节编辑器和自动保存
规划与上下文抽屉
写作提案面板
章节质检面板
Agent 任务面板
协作总览
全局任务中心
移动端降级视图

它们共同解决一个问题:

让用户知道:
我在哪里
AI 在做什么
它基于什么做
我现在能点什么
点了是否安全
失败后怎么恢复

这就是我理解的 Agent 前端工程化。

不是让 AI 看起来更智能。

而是让用户在复杂流程里仍然有控制感。

如果前面几篇讲的是 Agent 的后端能力,那么这一篇讲的是:

如何把这些能力交到用户手里,而且不让用户替系统承担复杂性。

下一篇,也是这个系列的收束篇,我会从架构师视角复盘:如何带团队把 Agent 从概念推进到可用产品。