本文是 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 从概念推进到可用产品。