diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx
index f41eaaa20..cb799730a 100644
--- a/apps/ai-game-creator-shell/src/App.tsx
+++ b/apps/ai-game-creator-shell/src/App.tsx
@@ -249,6 +249,7 @@ interface AgentRuntimeState {
lastResponse: string | null;
error: string | null;
updatedAt: number;
+ recentEvents?: AgentRuntimeEventRecord[];
recentTasks?: AgentRuntimeTaskRecord[];
}
@@ -304,7 +305,7 @@ interface AgentRuntimeResult {
sessionPath: string;
eventPath: string;
taskPath?: string;
- recentEvents: AgentRuntimeEventRecord[];
+ recentEvents?: AgentRuntimeEventRecord[];
recentTasks?: AgentRuntimeTaskRecord[];
}
@@ -514,28 +515,43 @@ function createAgentChatRunId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
-function agentRuntimeStateFromResult(
- result: AgentRuntimeResult,
+function normalizeAgentRuntimeState(
+ state: AgentRuntimeState,
+ previous?: AgentRuntimeState | null,
): AgentRuntimeState {
return {
- ...result.state,
- currentGoal: result.state.currentGoal ?? result.state.currentTask ?? '',
+ ...state,
+ currentGoal: state.currentGoal ?? state.currentTask ?? '',
waitingOn:
- result.state.waitingOn ?? agentRuntimeWaitingOnFromPhase(result.state.phase),
- nextStep:
- result.state.nextStep ?? agentRuntimeNextStepFromPhase(result.state.phase),
- recentToolCalls: result.state.recentToolCalls ?? [],
- toolPolicy: result.state.toolPolicy ?? {
- allowedTools: result.state.allowedTools ?? [],
+ state.waitingOn ?? agentRuntimeWaitingOnFromPhase(state.phase),
+ nextStep: state.nextStep ?? agentRuntimeNextStepFromPhase(state.phase),
+ recentToolCalls: state.recentToolCalls ?? previous?.recentToolCalls ?? [],
+ toolPolicy: state.toolPolicy ?? previous?.toolPolicy ?? {
+ allowedTools: state.allowedTools ?? [],
autoTools: [],
confirmTools: [],
deniedTools: [],
updatedAt: 0,
},
- recentTasks: result.recentTasks ?? result.state.recentTasks ?? [],
+ recentEvents: state.recentEvents ?? previous?.recentEvents ?? [],
+ recentTasks: state.recentTasks ?? previous?.recentTasks ?? [],
};
}
+function agentRuntimeStateFromResult(
+ result: AgentRuntimeResult,
+ previous?: AgentRuntimeState | null,
+): AgentRuntimeState {
+ return normalizeAgentRuntimeState(
+ {
+ ...result.state,
+ recentEvents: result.recentEvents ?? result.state.recentEvents,
+ recentTasks: result.recentTasks ?? result.state.recentTasks,
+ },
+ previous,
+ );
+}
+
function agentRuntimeWaitingOnFromPhase(phase: string) {
switch (phase) {
case 'planning':
@@ -583,6 +599,12 @@ function agentRuntimeStartStatus(result: AgentRuntimeResult) {
: `已启动后台任务:${result.state.runId}`;
}
+function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) {
+ const summary = event.summary || event.detail || event.runId;
+ const detail = event.detail && event.detail !== summary ? ` · ${event.detail}` : '';
+ return `${event.eventType} · ${event.status} / ${event.phase} · ${summary}${detail}`;
+}
+
function AgentRuntimeStatusPanel({
runtime,
error,
@@ -605,6 +627,7 @@ function AgentRuntimeStatusPanel({
}
const planItems = runtime.plan.slice(0, 3);
const observations = runtime.observations.slice(-2);
+ const recentEvents = (runtime.recentEvents ?? []).slice(-4).reverse();
const recentToolCalls = (runtime.recentToolCalls ?? []).slice(-3).reverse();
const recentTasks = (runtime.recentTasks ?? []).slice(-3).reverse();
const toolPolicy = runtime.toolPolicy;
@@ -649,6 +672,18 @@ function AgentRuntimeStatusPanel({
))}
) : null}
+ {recentEvents.length > 0 ? (
+
+ 最近事件
+ {recentEvents.map((event, index) => (
+
+ {formatAgentRuntimeEvent(event)}
+
+ ))}
+
+ ) : null}
{recentToolCalls.length > 0 ? (
最近动作
@@ -3295,7 +3330,9 @@ export function WorkspaceLauncher({
return;
}
if (payload.runtimeState) {
- setAgentChatRuntime(payload.runtimeState);
+ setAgentChatRuntime((current) =>
+ normalizeAgentRuntimeState(payload.runtimeState!, current),
+ );
setAgentChatRuntimeError('');
}
if (payload.status === 'started') {
@@ -12927,8 +12964,14 @@ export function App() {
return;
}
if (payload.runtimeState) {
- setAgentConversationRuntime(payload.runtimeState);
- rememberAgentRuntimeState(payload.runtimeState);
+ setAgentConversationRuntime((current) => {
+ const nextRuntime = normalizeAgentRuntimeState(
+ payload.runtimeState!,
+ current,
+ );
+ rememberAgentRuntimeState(nextRuntime);
+ return nextRuntime;
+ });
setAgentConversationRuntimeError('');
}
if (payload.status === 'started') {
@@ -19260,10 +19303,7 @@ export function App() {
}
setAgentRuntimeById((current) => {
const previous = current[runtime.agentId] ?? current[runtime.taskId];
- const mergedRuntime = {
- ...runtime,
- recentTasks: runtime.recentTasks ?? previous?.recentTasks ?? [],
- };
+ const mergedRuntime = normalizeAgentRuntimeState(runtime, previous);
return {
...current,
[mergedRuntime.agentId]: mergedRuntime,
diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts
index 201d544a9..e49a221e8 100644
--- a/apps/ai-game-creator-shell/tests/appSurface.test.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts
@@ -1143,6 +1143,22 @@ describe('AI 游戏创作 App 界面边界', () => {
],
updatedAt: 3001,
};
+ const loadedRuntimeEvents = [
+ {
+ schemaVersion: 'game-creator-agent-runtime.v1',
+ agentId: 'design-director',
+ taskId: 'design-director',
+ sessionId: 'agent-session-design-director',
+ runId: 'launcher-agent-chat-loaded',
+ source: 'agent-chat',
+ eventType: 'response',
+ status: 'idle',
+ phase: 'completed',
+ summary: '上一轮已完成回复',
+ detail: null,
+ updatedAt: 3002,
+ },
+ ];
const listen = vi.fn(
async (
eventName: string,
@@ -1201,7 +1217,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
- recentEvents: [],
+ recentEvents: loadedRuntimeEvents,
};
}
if (command === 'chat_with_game_creator_role_agent_stream') {
@@ -1295,6 +1311,10 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.getByText('当前目标:请流式回答')).not.toBeNull();
expect(screen.getAllByText('请求 Agent LLM').length).toBeGreaterThan(0);
expect(screen.getByText('等待:Agent LLM 回复')).not.toBeNull();
+ expect(screen.getByText('最近事件')).not.toBeNull();
+ expect(
+ screen.getByText('response · idle / completed · 上一轮已完成回复'),
+ ).not.toBeNull();
expect(screen.getByText('正在接收 Agent 回复')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_role_agent_stream',
@@ -1371,6 +1391,50 @@ describe('AI 游戏创作 App 界面边界', () => {
error: null,
updatedAt: 4000,
};
+ const runningRuntimeEvents = [
+ {
+ schemaVersion: 'game-creator-agent-runtime.v1',
+ agentId: 'design-director',
+ taskId: 'design-director',
+ sessionId: 'agent-session-design-director',
+ runId: 'launcher-agent-task-test',
+ source: 'agent-background-task',
+ eventType: 'thinking_summary',
+ status: 'running',
+ phase: 'planning',
+ summary: '需要先看项目笔记',
+ detail: null,
+ updatedAt: 4001,
+ },
+ {
+ schemaVersion: 'game-creator-agent-runtime.v1',
+ agentId: 'design-director',
+ taskId: 'design-director',
+ sessionId: 'agent-session-design-director',
+ runId: 'launcher-agent-task-test',
+ source: 'agent-background-task',
+ eventType: 'action',
+ status: 'running',
+ phase: 'action',
+ summary: '调用 file.read',
+ detail: 'game/notes.txt',
+ updatedAt: 4002,
+ },
+ {
+ schemaVersion: 'game-creator-agent-runtime.v1',
+ agentId: 'design-director',
+ taskId: 'design-director',
+ sessionId: 'agent-session-design-director',
+ runId: 'launcher-agent-task-test',
+ source: 'agent-background-task',
+ eventType: 'observation',
+ status: 'running',
+ phase: 'action',
+ summary: 'file.read 返回项目笔记',
+ detail: '已读取 game/notes.txt',
+ updatedAt: 4003,
+ },
+ ];
const invoke = vi.fn(
async (command: string, args?: Record
) => {
if (command === 'check_game_creator_llm_config') {
@@ -1417,7 +1481,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
- recentEvents: [],
+ recentEvents: runningRuntimeEvents,
recentTasks: [runningRuntimeTask],
};
}
@@ -1439,7 +1503,7 @@ describe('AI 游戏创作 App 界面边界', () => {
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
- recentEvents: [],
+ recentEvents: runningRuntimeEvents,
recentTasks: [{ ...runningRuntimeTask, runId }],
};
}
@@ -1471,6 +1535,18 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.getByText('回复开发者')).not.toBeNull();
expect(screen.getByText('思考摘要:需要先看项目笔记')).not.toBeNull();
expect(screen.getByText('file.read:ok · 已读取 game/notes.txt')).not.toBeNull();
+ expect(screen.getByText('最近事件')).not.toBeNull();
+ expect(
+ screen.getByText(
+ 'observation · running / action · file.read 返回项目笔记 · 已读取 game/notes.txt',
+ ),
+ ).not.toBeNull();
+ expect(
+ screen.getByText('action · running / action · 调用 file.read · game/notes.txt'),
+ ).not.toBeNull();
+ expect(
+ screen.getByText('thinking_summary · running / planning · 需要先看项目笔记'),
+ ).not.toBeNull();
expect(screen.getByText('最近任务')).not.toBeNull();
expect(screen.getByText(/running \/ action · 后台整理角色规范/)).not.toBeNull();
expect(screen.getByText(/已启动后台任务:launcher-agent-task-/)).not.toBeNull();
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 9aea119b4..89aa40aef 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -4061,6 +4061,7 @@
- 2026-07-10 调整:Agent Runtime state 新增 `toolPolicy`,从项目权限策略派生工具级 `allowedTools`、`autoTools`、`confirmTools` 和 `deniedTools`。后台 planning prompt 必须带入该快照,让 Agent 在规划阶段知道工具策略;执行阶段仍由 Runtime 白名单和项目权限 gate 决定。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略。
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。
- 2026-07-10 调整:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`。`currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。
+- 2026-07-10 调整:开发单 Agent 对话页和项目内 Agent 对话弹窗的 Runtime 面板接入 `recentEvents`,展示最近 `thinking_summary / plan / action / observation / response / error` 事件,避免只从当前状态、observation 字符串或最近工具动作里倒推 Agent loop。
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `task.list`。Agent 可自行读取 `.agent/manifest.json` 的 seed task 状态、依赖、产物交接和按依赖计算的 `readyTaskIds`,用于判断下一步任务;该工具必须受 `task.list` 项目权限策略保护,策略要求确认或拒绝时不得把任务图细节放进 observation。
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `task.update`。Agent 只能把 `.agent/manifest.json` 中已有 seed task 的状态更新为 `pending`、`running`、`waiting-for-confirmation`、`completed` 或 `failed`,Runtime 必须复用项目写锁、`task.update` 权限策略和 `.agent/agent.db` 审计记录;策略要求确认或拒绝时不得修改 manifest,不得创建新任务。
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `file.list`。Agent 可自行列出项目文件摘要或相对路径范围内的条目,先观察项目结构再决定是否读取具体文件;该工具必须受 `file.list` 项目权限策略保护,observation 只返回项目相对路径、类型和大小,不读取文件内容、不返回本机绝对路径。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 94bba86f1..aa72ae02d 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -32,10 +32,11 @@ Agent Runtime 负责:
- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。这里的 `` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。
- 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。
- 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。
-- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `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` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略要求确认或拒绝时不执行写入、运行或委派,只把策略结果作为 observation 回给 Agent。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表展示最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。
+- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `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` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略要求确认或拒绝时不执行写入、运行或委派,只把策略结果作为 observation 回给 Agent。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。
- 2026-07-10 补充:Agent Runtime state 新增 `toolPolicy`,按当前项目 `.agent/policy.json` 派生工具级 `allowedTools / autoTools / confirmTools / deniedTools` 快照;后台 planning prompt 会带入该快照,让 Agent 在规划时知道哪些工具会自动执行、需要确认或被拒绝。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略;实际执行仍以 Runtime 的白名单和项目权限 gate 为准。
- 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”,不再只能从 observation 字符串里猜测 action / observation 对应关系。字段只保存过滤后的摘要和观察细节,不保存原始 API Key 或任意未过滤输入。
- 2026-07-10 补充:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。
+- 2026-07-10 补充:`recentEvents` 接入前端归一态和 Runtime 状态面板,事件事实源仍是 `.agent/runtime/events/.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。
- 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。
- 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。
- 2026-07-10 补充:后台任务工具箱已加入 `canvas.asset_generate`。Agent 可在 loop 中自行给出素材 prompt,通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API 生成首版美术素材、下载到 `assets/canvas-generated/` 并登记 manifest;Runtime 复用 `canvas.asset_generate` 策略和项目写锁,并写入 `agent.runtime.canvas.asset_generate` 审计记录。API Key 不进入 observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。