修复Agent聊天流式响应与滚动反馈

兼容OpenAI-compatible SSE中choices为null的元数据帧

固定开发Agent聊天消息区高度并保留内部滚动

补齐流式协议、回退路径、界面约束测试和文档
This commit is contained in:
AIGameCreator App
2026-07-12 08:11:36 +08:00
parent 8493bb39cd
commit 427820089d
6 changed files with 26 additions and 8 deletions
@@ -2199,7 +2199,7 @@ async fn chat_with_game_creator_role_agent_stream_falls_back_once_before_first_d
let (base_url, stop_sender, server_handle) = spawn_mock_llm_stream_fallback_server(
"200 OK",
"text/event-stream; charset=utf-8",
"data: {\"choices\":null}\n\n".to_string(),
"data: {\"choices\":{}}\n\n".to_string(),
fallback_body,
);
let _config_guard = write_test_local_config(format!(
+3 -3
View File
@@ -1263,9 +1263,9 @@ textarea {
display: grid;
align-content: start;
gap: 10px;
height: 100%;
max-height: none;
min-height: 0;
height: clamp(260px, 40vh, 380px);
min-height: 260px;
max-height: 380px;
padding: 14px;
overflow-y: auto;
overscroll-behavior: contain;
@@ -16765,7 +16765,7 @@ describe('AI 游戏创作 App 界面边界', () => {
/\.launcher-agent-chat-main\s*\{[^}]*grid-template-rows:[^;]*clamp\(260px,\s*40vh,\s*380px\)/s,
);
expect(styles).toMatch(
/\.launcher-agent-chat-messages\s*\{[^}]*grid-row:\s*5[^}]*overflow-y:\s*auto/s,
/\.launcher-agent-chat-messages\s*\{[^}]*grid-row:\s*5[^}]*height:\s*clamp\(260px,\s*40vh,\s*380px\)[^}]*overflow-y:\s*auto/s,
);
expect(styles).toMatch(
/\.launcher-agent-chat-composer\s*\{[^}]*grid-row:\s*6/s,
@@ -4148,7 +4148,7 @@
- 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。
- 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。消息区只在用户仍接近底部时自动跟随最新片段,用户向上查看历史后暂停跟随,切换会话、重新读取或主动发送时再恢复。
- 2026-07-12 修正:OpenAI-compatible 流式响应的空 `choices` usage / metadata 包不得再报缺少 `choices[0]`。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。
- 2026-07-12 修正:OpenAI-compatible 流式响应 `choices` 为空数组或 `null` usage / metadata 包不得再报缺少 `choices[0]`,必须跳过元数据并继续等待正文。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。
- 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。
- 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。
- 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。
@@ -54,7 +54,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,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime stateOpenAI Chat SSE 的空 `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usagefinish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。
- 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime stateOpenAI Chat SSE 的空数组或 `null` `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usagefinish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。
- 2026-07-12 调整:开发单 Agent 对话框新增 `执行 / 聊天` 分段模式,默认 `执行`。默认发送直接调用 `start_game_creator_agent_runtime_task`,复用工具规划、权限确认、取消、队列和 Runtime 实时状态;`聊天` 作为显式模式继续走不执行工具的流式回复。消息区在 Runtime 启动、排队、等待 LLM、执行工具、等待确认和同步终态回复期间持续显示当前状态,不再要求开发者从页头文案猜测请求是否仍在运行;原独立“后台运行”按钮移除。Runtime 必须先把最终 assistant 回复可靠写入当前 Agent Session,再写 completed 终态并广播事件;对话写入失败时本轮进入 failed,不得产生 completed 记录。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。
- 2026-07-11 补充:后台单 Agent 新增 Codex 风格的代码导航与局部编辑闭环。`project.search` 接受 `query / path / maxResults / caseSensitive`,在项目边界内做字面量搜索并返回 `path:line`,最多扫描 500 个、单个不超过 512 KiB 的文本文件,跳过 `.agent``.git``node_modules``dist``build``target``.next``coverage``.env*`;该工具映射到 `file.read` 权限。`file.read` 接受 `startLine / maxLines`,返回带行号的指定片段、总行数和下一页提示,单次最多 240 行、8,000 字符。`file.patch` 接受 `path / oldText / newText / expectedReplacements`,只在实际匹配数与预期一致时持锁写入,目标文件和修改后文件最大 2 MiB,成功后写 `agent.runtime.file.patch` 审计;该工具映射到 `file.write` 权限。Agent planning prompt 明确要求批量修改前创建 checkpoint,并可在修改后再次 `file.read` 验证;本轮不开放任意 shell 命令。
- 2026-07-11 补充,2026-07-12 更新:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds``script` 允许项目根 `package.json` 中的固定脚本 `check / typecheck / test / lint / build`,以及以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本。脚本必须真实存在于项目根普通文件 `package.json``scripts` 中,`expectedCommand` 必须与执行时重新读取的脚本正文完全一致,`timeoutSeconds` 为 1-300;当前执行器只支持 npm,非 npm `packageManager` 或 pnpm / yarn / bun 锁文件明确失败,不接受自由命令、参数或工作目录。`pre* / post*` 生命周期脚本名不在允许范围,执行器再通过 npm `--ignore-scripts` 禁止所选脚本关联的 pre/post lifecycle。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command runAgent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。最新验证失败,或验证通过后又执行 `file.write / file.patch / project.restore` 时,空 actions 不再代表完成,Runtime 会注入 `runtime.verification: blocked` 并继续 replan;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke``game.static_smoke` 保持原边界,不暴露该开发工具。
@@ -347,7 +347,7 @@ game-project/
- 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md``memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents/<group>/<role>.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。
- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.<agentId>` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/<agentId>.jsonl`。这里的 `<agentId>` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/<agentId>.json``.agent/runtime/events/<agentId>.jsonl`,字段包含 `agentId``taskId``sessionId``runId``source``status``phase``currentTask``currentGoal``currentAction``waitingOn``nextStep``loopIteration``maxLoopIterations``toolActionBudget``plan``planSteps``activePlanStepIndex``observations``recentToolCalls``toolPolicy``allowedTools``lastResponse``error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask``currentGoal`、event detail、`lastResponse``agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents/<group>/<role>.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md``memory/agents/<group>/<role>.md`
- 2026-07-10 补充:每个 Agent 支持多个持久化 Session。旧 `agent-session-<agentId>` 永久映射原 `.agent/conversations/agents/<agentId>.jsonl`,不迁移、不复制、不删除;新 Session 写入 `.agent/conversations/agents/<agentId>/sessions/<sessionId>.jsonl`,目录索引和 active Session 写入 `.agent/runtime/sessions/<agentId>.json`。开发单 Agent 聊天页提供创建、切换、归档和已归档只读查看;归档只更新元数据,不截断对话。直接聊天、流式回调和后台任务在启动时捕获 `agentId + sessionId`Runtime task / event 继续使用每 Agent append-only JSONL,但列表、任务队列、连续上下文和最近对话按 Session 过滤;Runtime 内的 `conversation.read` 和 self `agent.run_status` 通过 runId 继续使用该 Session,恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。同一 Agent 仍共享一把 OS 锁并严格串行,不允许借 Session 绕过 pending、确认或 `needs-reconciliation` 屏障;不同 Agent 仍可并行。
- 2026-07-12 补充:开发单 Agent 聊天页的 Runtime 容器即使为空也必须保留网格行位,消息区和输入区固定落在第 5、6 行;长历史只增加消息区 `scrollHeight`,不得改变主面板高度。消息区仅在滚动位置接近底部时自动跟随最新回复,用户主动向上查看历史后,状态变化和流式片段不得强行拉回底部;切换会话、重新读取历史或主动发送新任务时恢复跟随。保存用户消息、连接 LLM、等待首个片段和流式接收期间,消息区持续显示等待状态并标记 `aria-busy=true`。OpenAI-compatible 流式响应中的空 `choices` usage / metadata 包必须正常消费;首个片段前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误可由 Rust 层回退一次非流式请求,鉴权、额度、上游状态、超时和连接错误不得由前端再次请求。
- 2026-07-12 补充:开发单 Agent 聊天页的 Runtime 容器即使为空也必须保留网格行位,消息区和输入区固定落在第 5、6 行;消息区自身使用 `clamp(260px, 40vh, 380px)` 明确高度,长历史只增加消息区 `scrollHeight`,不得改变主面板高度。消息区仅在滚动位置接近底部时自动跟随最新回复,用户主动向上查看历史后,状态变化和流式片段不得强行拉回底部;切换会话、重新读取历史或主动发送新任务时恢复跟随。保存用户消息、连接 LLM、等待首个片段和流式接收期间,消息区持续显示等待状态并标记 `aria-busy=true`。OpenAI-compatible 流式响应中的空数组或 `null` `choices` usage / metadata 包必须正常消费;首个片段前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误可由 Rust 层回退一次非流式请求,鉴权、额度、上游状态、超时和连接错误不得由前端再次请求。
- 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / eventactive 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。主窗口 Agent 状态列表、开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、当前目标、动作、等待对象、下一步、计划、观测和最近工具动作;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。
- `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register``asset.update`v1 不引入 SQLite 依赖。
- `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。
+18
View File
@@ -421,10 +421,19 @@ enum ChatCompletionsResponsePayload {
struct ChatCompletionsResponseEnvelope {
id: Option<String>,
model: Option<String>,
#[serde(default, deserialize_with = "deserialize_nullable_vec")]
choices: Vec<ChatCompletionsChoice>,
usage: Option<LlmTokenUsage>,
}
fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Deserialize)]
struct ChatCompletionsChoice {
#[serde(default)]
@@ -3236,6 +3245,7 @@ mod tests {
body: concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n",
"data: {\"choices\":[]}\n\n",
"data: {\"choices\":null}\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",
@@ -3457,6 +3467,14 @@ mod tests {
assert!(event.is_none());
}
#[test]
fn chat_sse_ignores_null_choices_metadata_event() {
let event = parse_sse_event_block(LlmApiKind::OpenAiChat, "data: {\"choices\":null}")
.expect("null choices metadata should not fail the stream");
assert!(event.is_none());
}
#[tokio::test]
async fn stream_run_accumulates_responses_sse_response() {
let server_url = spawn_mock_server(vec![MockResponse {