修复Agent流式回复与等待状态

兼容OpenAI Chat SSE用量尾包并保留异常空响应校验
流式监听或首包前失败时自动降级普通回复
固定聊天消息区高度并启用内部滚动与可靠滚底
在等待LLM期间显示动态连接和接收状态
补充SSE协议与客户端降级回归测试
同步Agent聊天技术方案与项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-11 18:34:10 +08:00
parent 47ad28623a
commit 83c2ddb8bb
6 changed files with 168 additions and 22 deletions
+64 -11
View File
@@ -2965,6 +2965,7 @@ export function WorkspaceLauncher({
const [agentChatInput, setAgentChatInput] = useState('');
const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent');
const [agentChatBusy, setAgentChatBusy] = useState(false);
const agentChatMessagesRef = useRef<HTMLDivElement | null>(null);
const [agentChatBackgroundBusy, setAgentChatBackgroundBusy] = useState(false);
const [agentChatLlmConfigStatus, setAgentChatLlmConfigStatus] =
useState<GameCreatorLlmConfigStatus | null>(null);
@@ -2988,6 +2989,13 @@ export function WorkspaceLauncher({
const agentChatActiveSessionIdRef = useRef(agentChatActiveSessionId);
agentChatActiveSessionIdRef.current = agentChatActiveSessionId;
useLayoutEffect(() => {
const messageList = agentChatMessagesRef.current;
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [agentChatBusy, agentChatMessages, agentChatStatus]);
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
@@ -4219,6 +4227,7 @@ export function WorkspaceLauncher({
let savedUserResult: LocalConversationResult | null = null;
let stopStreamListen: (() => void) | null = null;
let streamListenDisposed = false;
let streamListenReady = false;
let streamFrameId: number | null = null;
let pendingStreamDraftText = '';
let pendingStreamFinishReason: string | null = null;
@@ -4250,8 +4259,8 @@ export function WorkspaceLauncher({
const streamRunId = createAgentChatRunId('launcher-agent-chat');
const listen = window.__TAURI__?.event?.listen;
if (listen) {
stopStreamListen =
await listen<GameCreatorRoleAgentChatStreamEvent>(
try {
stopStreamListen = await listen<GameCreatorRoleAgentChatStreamEvent>(
'game-creator-role-agent-chat-stream',
(event) => {
const payload = event.payload;
@@ -4274,7 +4283,9 @@ export function WorkspaceLauncher({
}
if (payload.status === 'started') {
setAgentChatStatus(
payload.runtimeSummary ?? 'Agent 已连接,正在等待回复',
payload.runtimeSummary
? `已连接 Agent LLM${payload.runtimeSummary}`
: '已连接 Agent LLM,正在等待首个回复片段',
);
return;
}
@@ -4327,13 +4338,24 @@ export function WorkspaceLauncher({
}
},
);
if (streamListenDisposed) {
stopStreamListen();
stopStreamListen = null;
streamListenReady = true;
if (streamListenDisposed) {
stopStreamListen();
stopStreamListen = null;
}
} catch {
setAgentChatStatus('实时状态不可用,正在使用普通回复模式');
}
}
const reply = listen
? await invoke<GameCreatorChatAgentReply>(
setAgentChatStatus(
streamListenReady
? '已连接 Agent LLM,正在等待首个回复片段'
: '正在等待 Agent LLM 回复',
);
let reply: GameCreatorChatAgentReply;
if (streamListenReady) {
try {
reply = await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent_stream',
{
projectPath: projectPathForChat,
@@ -4342,8 +4364,13 @@ export function WorkspaceLauncher({
runId: streamRunId,
...agentChatSessionInvokeArgs(sessionIdForChat),
},
)
: await invoke<GameCreatorChatAgentReply>(
);
} catch (error) {
if (pendingStreamDraftText) {
throw error;
}
setAgentChatStatus('流式连接失败,正在切换普通回复模式');
reply = await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: projectPathForChat,
@@ -4352,6 +4379,18 @@ export function WorkspaceLauncher({
...agentChatSessionInvokeArgs(sessionIdForChat),
},
);
}
} else {
reply = await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: projectPathForChat,
agentId: agent.id,
prompt: content,
...agentChatSessionInvokeArgs(sessionIdForChat),
},
);
}
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
@@ -5683,7 +5722,11 @@ export function WorkspaceLauncher({
</div>
) : null}
</div>
<div className="launcher-agent-chat-messages" aria-label="Agent 聊天记录">
<div
ref={agentChatMessagesRef}
className="launcher-agent-chat-messages"
aria-label="Agent 聊天记录"
>
{agentChatMessages.length > 0 ? (
agentChatMessages.map((message, index) => (
<p
@@ -5696,6 +5739,16 @@ export function WorkspaceLauncher({
) : (
<p className="status-line"></p>
)}
{agentChatBusy ? (
<div
className="launcher-agent-chat-waiting"
role="status"
aria-live="polite"
>
<span aria-hidden="true" />
<strong>{agentChatStatus}</strong>
</div>
) : null}
</div>
<form
className="launcher-agent-chat-composer"
+44 -3
View File
@@ -1149,7 +1149,7 @@ textarea {
.launcher-agent-chat-main {
display: grid;
grid-template-rows: auto auto auto auto minmax(320px, auto) auto;
grid-template-rows: repeat(6, auto);
overflow: visible;
}
@@ -1259,9 +1259,12 @@ textarea {
display: grid;
align-content: start;
gap: 10px;
min-height: 320px;
height: clamp(280px, 44vh, 420px);
min-height: 0;
padding: 14px;
overflow: visible;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.launcher-agent-chat-messages .message {
@@ -1273,6 +1276,44 @@ textarea {
justify-self: end;
}
.launcher-agent-chat-waiting {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: 9px;
width: fit-content;
max-width: 100%;
padding: 9px 11px;
border: 1px solid #d8dde5;
border-radius: 8px;
background: #f8fafc;
box-shadow: 0 4px 12px rgb(15 23 42 / 8%);
color: #374151;
}
.launcher-agent-chat-waiting > span {
width: 14px;
height: 14px;
flex: 0 0 auto;
border: 2px solid #cbd5e1;
border-top-color: #111827;
border-radius: 50%;
animation: launcher-agent-chat-spin 0.8s linear infinite;
}
.launcher-agent-chat-waiting strong {
min-width: 0;
font-size: 12px;
overflow-wrap: anywhere;
}
@keyframes launcher-agent-chat-spin {
to {
transform: rotate(360deg);
}
}
.launcher-agent-chat-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -984,6 +984,9 @@ describe('AI 游戏创作 App 界面边界', () => {
})),
};
}
if (command === 'chat_with_game_creator_role_agent_stream') {
throw new Error('LLM SSE 响应缺少 choices[0]');
}
if (command === 'chat_with_game_creator_role_agent') {
return {
replyText: mockRoleAgentReply(),
@@ -1009,7 +1012,8 @@ describe('AI 游戏创作 App 界面边界', () => {
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const listen = vi.fn(async () => () => {});
window.__TAURI__ = { core: { invoke }, event: { listen } };
renderLauncherAgentChatAt('/?agent-chat');
expect(screen.getByText('Agent 聊天')).not.toBeNull();
@@ -1061,6 +1065,18 @@ describe('AI 游戏创作 App 界面边界', () => {
agentId: 'design-director',
prompt: '请单独评估这个角色设定流程',
});
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_role_agent_stream',
expect.objectContaining({
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
prompt: '请单独评估这个角色设定流程',
}),
);
expect(listen).toHaveBeenCalledWith(
'game-creator-role-agent-chat-stream',
expect.any(Function),
);
});
it('asks before recovering interrupted runtime tasks in the developer Agent chat', async () => {
@@ -1480,6 +1496,7 @@ describe('AI 游戏创作 App 界面边界', () => {
content: string;
agentId: string | null;
}> = [];
let releaseFirstDelta: (() => void) | null = null;
let releaseStream: (() => void) | null = null;
let streamHandler: ((event: { payload: Record<string, unknown> }) => void) | null =
null;
@@ -1615,6 +1632,9 @@ describe('AI 游戏创作 App 界面边界', () => {
runtimeState: startedRuntimeState,
},
});
await new Promise<void>((resolve) => {
releaseFirstDelta = resolve;
});
streamHandler?.({
payload: {
...payloadBase,
@@ -1682,6 +1702,14 @@ describe('AI 游戏创作 App 界面边界', () => {
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
const waitingForFirstDelta = await within(
screen.getByLabelText('Agent 聊天记录'),
).findByRole('status');
expect(waitingForFirstDelta.textContent).toContain('已连接 Agent LLM');
await act(async () => {
releaseFirstDelta?.();
});
expect(await screen.findByText('专业 Agent')).not.toBeNull();
expect(await screen.findByLabelText('Agent Runtime 状态')).not.toBeNull();
expect(screen.getByText('running / llm')).not.toBeNull();
@@ -1693,7 +1721,11 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(
screen.getByText('response · idle / completed · 上一轮已完成回复'),
).not.toBeNull();
expect(screen.getByText('正在接收 Agent 回复')).not.toBeNull();
expect(screen.getAllByText('正在接收 Agent 回复').length).toBeGreaterThan(0);
const waitingStatus = within(
screen.getByLabelText('Agent 聊天记录'),
).getByRole('status');
expect(waitingStatus.textContent).toContain('正在接收 Agent 回复');
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_role_agent_stream',
expect.objectContaining({
@@ -1715,6 +1747,9 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.getAllByText('等待下一轮输入').length).toBeGreaterThan(0);
expect(screen.getByText('等待:开发者下一轮输入')).not.toBeNull();
expect(await screen.findByText(/已保存 2 条/)).not.toBeNull();
expect(
within(screen.getByLabelText('Agent 聊天记录')).queryByRole('status'),
).toBeNull();
expect(persistedMessages).toEqual([
{
role: 'user',
@@ -21,7 +21,7 @@
- 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。
- 决策:在现有 `.agent/runtime``.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loopAgent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read``conversation.read``asset.list``project.index``project.diff``file.list``file.read``agent.run_status`,以及受策略保护的写/运行工具 `memory.write``file.write``command.run_limited``blackboard.write``agent.message``agent.delegate``memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`Runtime state 增加 `nextStep`UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
- 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents``events``tasks` 文件。
- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;连续流式 delta 合并到动画帧更新并跳过 delta 内重复 Runtime state 提交
- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区显示动态状态,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 的空 `choices` usage 事件不再报错,事件监听不可用或首个文本片段前流式失败时降级普通回复
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。
- 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan``code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/<taskId>.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。
@@ -52,7 +52,7 @@ Agent Runtime 负责:
- 2026-07-10 补充:`recentEvents` 接入前端归一态和 Runtime 状态面板,事件事实源仍是 `.agent/runtime/events/<agentId>.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。
- 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents``events``tasks` 仍是重开项目后的事实源。
- 2026-07-10 补充:后台 Agent loop 的统一语义事件类型为 `thinking_summary / plan / action / observation / response / error`。普通失败和 loop 预算耗尽都会追加 `error` 事件,并继续保留 `turn.failed / turn.budget_exhausted` 生命周期事件兼容既有读取方;开发窗口、项目内 Agent 对话弹窗和主窗口状态卡通过现有最近事件列表直接展示统一错误事件及其安全详情。状态面板默认保持最新 4 条的紧凑视图,当前后端返回的最近事件超过 4 条时可展开查看全部返回记录,确保同一 run 的六类语义事件不会因 UI 硬截断而无法检查。
- 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 和聊天记录锁进固定视口或嵌套纵向滚动区;可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state,控制流式回复期间的布局与重绘范围
- 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区显示连接 / 等待首包 / 接收中的动态状态。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime stateOpenAI Chat SSE 的 `choices: []` usage 事件按非内容事件跳过,事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复
- 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/<agentId>.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。
- 2026-07-10 补充:Runtime 新增 `agent.schedule_ready` 调度入口。开发构建可在权限确认后扫描 manifest ready task,把依赖已完成且仍为 `pending` 的任务标成 `running`,并按 taskId 投递到对应 Agent 的既有后台队列;source 固定为 `agent-ready-task-scheduler`,审计记录写 `agent.runtime.ready_task.scheduled`。该入口只把 manifest ready task 接入现有 per-agent 队列、锁、JSONL、LLM loop、工具策略和事件流,不新增独立 worker,也不会在默认确认策略下静默启动。
- 2026-07-10 补充:主窗口 Agent 状态栏的“调度 Ready”只在开发模式显示。点击后复用项目策略确认弹窗,确认通过才调用 `schedule_game_creator_agent_ready_tasks`,并把返回的 Runtime 合并回 Agent 状态卡;普通用户窗口继续只展示状态和单 Agent 对话入口,不直接暴露 ready-task 调度按钮。
+21 -4
View File
@@ -1822,10 +1822,15 @@ fn parse_sse_event_block(
let parsed: ChatCompletionsResponseEnvelope = serde_json::from_str(data.as_str())
.map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?;
let first_choice = parsed
.choices
.first()
.ok_or_else(|| LlmError::Deserialize("LLM SSE 响应缺少 choices[0]".to_string()))?;
let Some(first_choice) = parsed.choices.first() else {
return if parsed.usage.is_some() {
Ok(None)
} else {
Err(LlmError::Deserialize(
"LLM SSE 响应缺少 choices[0]".to_string(),
))
};
};
Ok(Some(ParsedStreamEvent {
delta_text: extract_message_text(first_choice),
@@ -2538,6 +2543,7 @@ mod tests {
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
@@ -2562,6 +2568,17 @@ mod tests {
assert_eq!(response.response_id.as_deref(), Some("req_stream_01"));
}
#[test]
fn chat_sse_rejects_empty_choices_without_usage() {
let error = parse_sse_event_block(LlmApiKind::OpenAiChat, "data: {\"choices\":[]}")
.expect_err("empty choices without usage should remain a protocol error");
assert_eq!(
error,
LlmError::Deserialize("LLM SSE 响应缺少 choices[0]".to_string())
);
}
#[tokio::test]
async fn stream_run_accumulates_responses_sse_response() {
let server_url = spawn_mock_server(vec![MockResponse {