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

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

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

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

这当然是最直观的形态。

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

它会同时涉及:

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

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

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

所以这一篇我想讲的是:

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

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

01. 先给结论

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

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

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

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

一句话概括:

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

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

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

text 复制代码
左边一堆指标
中间一堆卡片
右边一堆日志
顶部一堆按钮

看起来很专业。

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

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

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

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

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

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

这些字段看起来很后端。

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

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

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

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

入口页面非常简单:

tsx 复制代码
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>
    );
}

桌面端是主战场。

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

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

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

tsx 复制代码
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. 聚合接口:复杂面板不能各查各的

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

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

会带来几个问题:

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

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

python 复制代码
@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()

python 复制代码
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)

这意味着:

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

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

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

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

后端返回的是数据。

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

useWritingWorkbench() 做了这层事:

tsx 复制代码
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 没有错误且不过期,可以执行命令

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

每个面板只要知道:

text 复制代码
当前数据是不是旧的?
当前面板能不能操作?

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

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

Agent 驾驶舱最怕一种问题:

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

所以 useWritingWorkbench() 里有一个 isCurrentChapter

tsx 复制代码
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),
  };
}

页面层再组合成:

tsx 复制代码
const hasCurrentWorkbench = Boolean(
  activeChapter
    && workbench.isCurrentChapter
    && workbench.aggregateChapterId === activeChapter.id,
);

然后传给提案和质检:

tsx 复制代码
<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}
/>

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

tsx 复制代码
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 命令提供几个能力:

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

DesktopWorkspaceBody 暴露了这些动作:

tsx 复制代码
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;
}

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

tsx 复制代码
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]);
}

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

tsx 复制代码
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 命令可能基于旧正文执行。

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

它们要先:

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

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

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

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

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

tsx 复制代码
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]);

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

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

text 复制代码
advanceDraftBase(...)

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

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

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

这对 Agent 按钮很重要。

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

前端驾驶舱要保证:

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

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

这个项目有一条硬约束:

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

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

提案面板的操作很直观:

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

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

tsx 复制代码
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('草稿没能保存成功,操作没完成。');
    }
  })();
};

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

tsx 复制代码
{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 或聚合过期 只读
连续性阻断 展示阻断原因
缺少章节计划 告诉用户先完成计划

这段提示很典型:

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

它不是单纯报错。

它告诉用户:

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

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

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

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

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

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

tsx 复制代码
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,
    };
  },
  [],
);

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

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

text 复制代码
start_offset
end_offset
selection_hash
expected_revision

这样后端可以判断:

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

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

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

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

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

质检结果最容易被误用。

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

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

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

tsx 复制代码
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,
);

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

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

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

tsx 复制代码
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>

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

tsx 复制代码
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('草稿没能保存成功,修复提案没建成。');
    }
  })();
};

测试也覆盖了这个边界:

tsx 复制代码
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(),质检面板调用它:

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

<QualityIssuePanel
  onLocate={locateQualityIssue}
/>

面板内部:

tsx 复制代码
{!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>
  )}

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

tsx 复制代码
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 和长任务体验。

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

状态文案:

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

动作判断:

tsx 复制代码
function isActive(status: AgentTaskStatus) {
  return status === 'pending' || status === 'running';
}

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

渲染:

tsx 复制代码
{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>
)}

还会展示步骤和成本:

tsx 复制代码
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 完成了"。

他还能看到:

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

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

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

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

tsx 复制代码
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 进度。

比如:

text 复制代码
10% 读取上下文
30% 生成中
70% 审核中
100% 完成

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

当前系统里,进度来自 AgentStep

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

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

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

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

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

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

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

默认只展示摘要:

tsx 复制代码
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>
  );
}

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

tsx 复制代码
{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>
)}

这里有一个产品取舍:

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

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

tsx 复制代码
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:

tsx 复制代码
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;
      });
    }
  }
};

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

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

也提供取消:

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

这里的 operationEpoch 很实用。

它解决的是:

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

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

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

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

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

tsx 复制代码
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>
  );
}

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

text 复制代码
计划
正文
提案
质检
定稿

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

而是告诉他:

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

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

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

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

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

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

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

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

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

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

tsx 复制代码
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]);

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

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

这形成了两个视角:

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

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

一定要有全局任务中心。

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

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

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

tsx 复制代码
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>
  );
}

它不是新增功能。

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

text 复制代码
有几个任务还在跑
有几个提案等我看
质量有没有检查

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

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

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

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

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

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

这张表背后的原则是:

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

21. 总结

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

我的核心观点是:

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

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

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

它们共同解决一个问题:

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

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

不是让 AI 看起来更智能。

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

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

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

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

相关推荐
万里鹏程转瞬至2 小时前
论文简读:Boogu-Image 图像生成与编辑模型
论文阅读·深度学习·aigc
ZzT2 小时前
Caveman 翻车了:号称省 65% token,官方实测只有 8.5%
ai编程·jetbrains
Sirius Wu3 小时前
OpenClaw Model Provider(模型提供商)完整详解
服务器·网络·人工智能·安全·aigc
臼犀3 小时前
大语言模型响应延迟对软件工程师尿液浓缩程度的影响 —— 一项基于水杯见底速度的观察性研究
程序员·ai编程·vibecoding
神奇霸王龙3 小时前
AI 音乐作曲:LLM 歌词 + TTS 人声实战指南
人工智能·ai·prompt·aigc·音视频·ai音乐
长情_4 小时前
给 AI 编程助手装一个"代码大脑":我是怎么用知识图谱 + 语义检索解决 Agent 代码理解问题的
ai编程
码哥字节4 小时前
AI知识库搜不准?缺的不是更好的模型,是这三层内容筛选
ai编程
京东云开发者4 小时前
拆解海博 AI-Native 落地保障:Harness、双 Loop、知识库与技能自主迭代实践
llm·ai编程·前端工程化
Rain的Java大神实战圈4 小时前
致焦虑的程序员:AI 不会让你失业,但“写 CRUD”会
ai编程·架构设计