)
if (eventString.startsWith('data:')) {
const jsonData = eventString.substring(5).trim();
try {
const eventResponse = JSON.parse(jsonData) as SendTaskStreamingResponse;
if (eventResponse.error) {
const error = eventResponse.error;
console.error("Received SSE Error:", error);
setError(`流式错误: Code=${error.code}, Msg=${error.message}`);
streamEnded = true; // 出现错误,通常流会中断
break; // 停止处理此流
}
const eventData = eventResponse.result; // TaskStatusUpdateEvent or TaskArtifactUpdateEvent
if (!eventData) continue;
// 更新状态 (示例:将整个事件数据存入列表)
setProgressUpdates(prev => [...prev, eventData]);
// 可以在这里根据 eventData 的类型做更精细的状态更新
if ('status' in eventData) { // TaskStatusUpdateEvent
setTaskStatus(eventData.status.state as TaskState || 'WORKING'); // 更新宏观状态
}
if ('artifact' in eventData) { // TaskArtifactUpdateEvent
// 假设最终报告在 TextPart
const reportPart = eventData.artifact.parts?.find(p => p.type === 'text') as TextPart | undefined;
if(reportPart) {
setFinalReport(prev => (prev || '') + reportPart.text); // 可以累积或直接设置
}
}
// 检查是否是最终事件
if (eventData.final === true) {
console.log("Final event flag received from server.");
streamEnded = true;
// 最终状态应该由事件本身携带的状态决定
if ('status' in eventData) {
setTaskStatus(eventData.status.state as TaskState);
} else {
setTaskStatus(TaskState.COMPLETED); // 假定 Artifact 事件也是完成
}
break; // 收到 final=true,我们可以停止读取这个流了
}
} catch (e) {
console.error("Failed to parse SSE event data:", e, jsonData);
// 可以选择设置错误状态或继续处理下一个事件
}
} else {
// 处理其他 SSE 行 (如 event:, id:, retry:),如果需要的话
console.log("Received non-data SSE line:", eventString);
}
} // end for eventString in events
if (streamEnded) break; // 如果内部逻辑判断流应结束,则跳出外层循环
} // end while reader
} catch (err: any) {
console.error("Error reading stream:", err);
setError(`读取流失败: ${err.message}`);
setTaskStatus('FAILED'); // 流读取出错,标记失败
} finally {
// 确保 reader 被释放 (如果需要, though exiting loop usually suffices)
// reader.releaseLock(); ? (Check MDN docs if needed)
setIsLoading(false); // 确保加载状态结束
if (!streamEnded && taskStatus !== TaskState.COMPLETED && taskStatus !== TaskState.FAILED) {
// 如果流意外中断,设置一个合适的最终状态
setError("流连接意外断开");
setTaskStatus('FAILED'); // Or 'UNKNOWN'
}
console.log("Stream processing function finished.");
}
};
// 在你的 React 组件的 JSX 中:
//
//
//
//
//
```
**5. 处理 `DataPart` (在 `ProgressDisplay` 组件中):**
```typescript
// 假设 ProgressDisplay 组件接收 updates: any[]
const ProgressDisplay = ({ updates }: { updates: any[] }) => {
return (
{updates.map((eventData, index) => {
let content = null;
// 确定事件类型并提取 Parts
let parts: Part[] | undefined = undefined;
if (eventData && 'status' in eventData && eventData.status?.message?.parts) {
parts = eventData.status.message.parts;
} else if (eventData && 'artifact' in eventData && eventData.artifact?.parts) {
// 注意:通常最终报告才放在 artifact 里,但这里也检查一下
parts = eventData.artifact.parts;
}
if (parts) {
content = parts.map((part, partIndex) => {
if (part.type === 'text') {
// 渲染 TextPart
return
{part.text}
;
} else if (part.type === 'data') {
// 渲染 DataPart (示例:格式化 JSON)
const data = part.data;
// 尝试更友好的展示
const step = data?.step || data?.step_name;
const status = data?.status;
const detail = data?.detail || data?.message;
const query = data?.query;
const source = data?.source;
const count = data?.results_count;
let friendlyText = `[${step || '步骤未知'}] ${status ? '(' + status + ')' : ''}`;
if(source) friendlyText += ` 来源:${source}`;
if(query) friendlyText += ` 查询:'${query}'`;
if(count !== undefined) friendlyText += ` (${count}条结果)`;
if(detail && detail !== readable_summary) friendlyText += ` - ${detail}`; // 避免重复
return (
{friendlyText || `收到结构化数据 (点击展开)`}
{JSON.stringify(data, null, 2)}
);
}
// 可以添加对 FilePart 的处理
return null;
});
} else {
// 如果无法解析 parts,显示原始事件数据(用于调试)
content =
未知事件结构: {JSON.stringify(eventData)};
}
// 用一个容器包裹每次更新的内容
return
{content}
;
})}
);
};
```
**6. 注意事项和进一步优化:**
* **错误处理:** 上述代码包含了基本的错误处理,但生产环境需要更细致的处理,例如区分网络错误、服务器错误、JSON 解析错误等。
* **SSE 解析健壮性:** 手动解析 SSE 流需要仔细处理边界情况,例如事件跨多个 `read()` 调用到达、`retry:` 指令等。可以考虑使用成熟的前端 SSE 客户端库(如果它们支持通过 `Workspace` 的 `ReadableStream` 或允许自定义请求方式)。
* **状态更新频率:** 如果服务器发送更新过于频繁,可能会导致 React 状态更新过多影响性能。可以考虑进行节流 (throttling) 或批处理 (batching) 更新。
* **`DataPart` 的约定:** 为了让前端能“理解”并友好地展示 `DataPart` 的内容,前后端需要约定好 `data` 字段中可能包含的键名和结构。
* **中止请求:** 代码中加入了 `AbortController`,允许在用户发起新的请求或离开页面时中止正在进行的 `Workspace` 请求和流式读取。
* **类型安全:** 强烈建议在前端项目中也维护一套与 `core/a2a/types.py` 同步的 TypeScript 接口定义,以获得完整的类型检查好处。
================================================
FILE: web_for_a2a/README.md
================================================
# DeepResearch A2A Web UI
## 概述
本项目是一个基于 **Next.js**, **React**, **TypeScript** 和 **Tailwind CSS** 构建的 Web 用户界面 (UI),旨在与 **DeepResearch A2A (Agent-to-Agent) 服务器** 进行交互。用户可以通过此界面发起深度研究任务,并**实时查看**由服务器通过 Server-Sent Events (SSE) 推送的研究进度更新和最终生成的报告。
这个项目的主要目的是演示如何在现代 Web 前端应用中,使用浏览器原生 API (`Workspace`, `ReadableStream`) 来对接和处理符合 A2A 协议规范的流式响应。
## 特性
* **连接 A2A 服务:** 通过 HTTP 与指定的 DeepResearch A2A 服务器通信。
* **发起研究任务:** 向服务器发送符合 A2A `tasks/sendSubscribe` 规范的请求以启动流式研究任务。
* **实时流式更新:** 使用 `Workspace` API 的 `ReadableStream` 接收并解析来自服务器的 Server-Sent Events (SSE),实时展示任务进度。
* **结构化数据显示:** 能够区分并展示 A2A 事件中的 `TextPart` 和 `DataPart`。
* **最终报告展示:** 在任务完成后,提取并展示最终的研究报告。
* **基础状态与错误显示:** 提供简单的 UI 反馈,显示任务的当前状态(空闲、进行中、完成、错误)和遇到的问题。
## 技术栈
* **框架:** Next.js (App Router)
* **UI 库:** React
* **语言:** TypeScript
* **样式:** Tailwind CSS
* **核心 API:** Browser `Workspace` API, `ReadableStream`, `TextDecoder`
* **辅助库:** `uuid` (用于生成客户端 Task ID 示例)
## 目录结构 (相关部分)
```
mentis/
└── web_for_a2a/ # Web UI 项目根目录
├── app/ # Next.js App Router 目录
│ ├── api/
│ │ └── a2a/ # (可选)API Route 代理目录
│ │ └── [[...slug]]/
│ │ └── route.ts
│ ├── deepresearch/ # DeepResearch Agent 的 UI 页面
│ │ └── page.tsx # ★ UI 界面的核心实现文件
│ └── layout.tsx # 根布局
│ └── page.tsx # 根页面 (可能重定向或包含链接)
├── public/ # 静态资源
├── .env.local # (可选) 本地环境变量配置文件
├── next.config.js # Next.js 配置文件 (可能包含代理设置)
├── package.json
├── tailwind.config.ts
└── tsconfig.json
```
*(★ 表示本文档重点关注的文件)*
## 前提条件
* Node.js (推荐 LTS 版本) 和 npm / yarn / pnpm / uv 等包管理器。
* **DeepResearch A2A 后端服务器** 必须正在运行,并且其地址可访问(默认为 `http://127.0.0.1:8000`)。
* 对 React, Next.js, TypeScript 和 `Workspace` API 有基本了解。
## 安装与设置
1. **导航到目录:**
```bash
cd mentis/web_for_a2a
```
2. **安装依赖:** (根据你项目使用的包管理器选择)
```bash
npm install
# yarn install
# pnpm install
# uv sync
```
3. **(可选) 配置后端服务器地址:**
* 默认情况下,前端会尝试连接 `http://127.0.0.1:8000`。
* 如果你使用了 API Route 代理(如 `/api/a2a`),或者你的 A2A 服务器地址不同,可以在 `web_for_a2a` 目录下创建一个 `.env.local` 文件,并设置环境变量:
```dotenv
# .env.local
NEXT_PUBLIC_A2A_SERVER_URL=/api/a2a # 指向代理
# 或者
# NEXT_PUBLIC_A2A_SERVER_URL=http://your-backend-address:port # 直接指向后端
```
* **注意:** 环境变量名必须以 `NEXT_PUBLIC_` 开头,才能在浏览器端的代码中访问。`page.tsx` 中的代码 `process.env.NEXT_PUBLIC_A2A_SERVER_URL` 会读取这个值。
## 运行
1. **确保后端 A2A 服务器已启动。**
2. **启动 Next.js 开发服务器:**
```bash
npm run dev
# yarn dev
# pnpm dev
# uv run dev (如果配置了脚本)
```
3. **访问页面:** 在浏览器中打开 Next.js 应用的地址(通常是 `http://localhost:3000`),并导航到 DeepResearch 页面(例如 `http://localhost:3000/deepresearch`)。
## 使用说明
当前示例 UI 非常简单:
1. 页面加载后,你会看到一个标题和一个按钮。
2. 点击 **"开始流式研究 (特斯拉主题)"** 按钮。
3. 按钮会变为 "研究进行中..." 并禁用。
4. 页面上的 **"当前状态"** 会变为 `streaming`。
5. **"流式内容输出:"** 区域会开始实时显示从服务器推送过来的进度更新。你会看到 `[状态更新]` 或 `[收到报告片段]` 的标记,后面跟着相应的文本或结构化数据 (JSON 格式)。
6. 当研究完成或出错时,**"当前状态"** 会更新为 `completed` 或 `error`,按钮会重新启用。
7. 如果成功,最终的**研究报告**会显示在页面底部。
8. 如果过程中出现错误,错误信息会显示在状态下方。
## 核心实现:处理 A2A 流 (Fetch API + ReadableStream)
这是前端实现中最关键的部分,位于 `app/deepresearch/page.tsx` 的 `startStream` 和 `processStream` 函数中。
**为什么不直接用 `EventSource` API?**
标准的 `EventSource` 浏览器 API 非常适合接收 SSE,但它通常只能发起 `GET` 请求。而 A2A 协议规定启动流式任务 (`tasks/sendSubscribe`) 需要使用 `POST` 请求(因为要传递包含研究主题的 `message` 等参数)。为了在不修改标准 A2A 服务器行为的前提下实现此功能,我们选用了更底层的 `Workspace` API。
**`startStream` 函数主要流程:**
1. **重置状态:** 清空之前的输出、错误,设置状态为 `streaming`。
2. **创建 `AbortController`:** 用于在需要时(例如发起新请求或组件卸载)中止当前的 `Workspace` 请求。
3. **构建请求体:** 创建符合 A2A `tasks/sendSubscribe` 方法要求的 JSON-RPC 请求对象,包含 `method`, `id`, 以及 `params` (内含客户端生成的 `taskId`, `sessionId`, `message` 等)。
4. **发送 `Workspace` 请求:**
* 使用 `POST` 方法。
* 设置 `Content-Type: application/json` 和 `Accept: text/event-stream` 请求头。
* 将 JSON-RPC 对象字符串化后作为 `body`。
* 传入 `AbortController` 的 `signal`。
5. **检查初始响应:**
* 确认 `response.ok` (HTTP 状态码 2xx)。
* **关键检查:** 确认 `response.headers.get('content-type')` 包含 `text/event-stream`。如果不是,说明服务器未能成功建立 SSE 连接(可能是服务器端错误或未正确返回流类型),此时应抛出错误。
* **(调试日志)** 添加了打印所有响应头和 CORS 头 (`access-control-allow-origin`) 的日志,用于诊断连接问题。
6. **获取 `ReadableStream`:** 从 `response.body` 获取流式读取器 `reader`。
7. **调用 `processStream`:** 将 `reader` 传递给专门处理流的异步函数。
**`processStream` 函数主要流程 (SSE 解析核心):**
1. **初始化:** 创建 `TextDecoder` 用于将服务器发送的 `Uint8Array` 数据块解码为文本;创建一个 `buffer` 字符串用于处理跨数据块的、不完整的 SSE 消息。
2. **循环读取:** 使用 `while (true)` 和 `await reader.read()` 不断读取数据块。
3. **解码与缓冲:** 将读取到的 `value` (Uint8Array) 解码并追加到 `buffer`。
4. **分割 SSE 事件:** **关键步骤!** SSE 事件由两个连续的换行符 (`\n\n`, `\r\r`, 或 `\r\n\r\n`) 分隔。代码使用正则表达式 `/\r\n\r\n|\n\n|\r\r/` 来查找并分割出 buffer 中完整的事件字符串 (`eventString`)。未处理完的部分保留在 `buffer` 中供下次 `read()` 后拼接。
5. **解析单个 SSE 事件:**
* 对每个分割出的 `eventString` 进行处理。
* 按行 (`\n`, `\r`, `\r\n`) 分割事件内部。
* 遍历每一行,主要查找以 `data:` 开头的行,提取其后的 JSON 字符串 (`jsonData`)。SSE 事件可能包含多行 `data:`,代码会将其拼接起来。同时也处理 `event:`, `id:`, `retry:` 等标准 SSE 字段(虽然本示例主要关心 `data:`)。
* **关键解析:** 使用 `JSON.parse(jsonData)` 将提取到的字符串解析为 JavaScript 对象 (`eventResponse`,预期符合 `SendTaskStreamingResponse` 接口)。
* **添加了详细日志:** 在解析前后都打印了原始数据和解析结果,便于调试。
* **错误处理:** 如果 `JSON.parse` 失败,会捕获异常,调用 `setError` 更新 UI,并停止处理流。
6. **处理解析后的数据:**
* 检查 `eventResponse.error`,如果存在则报告错误并停止。
* 获取 `eventData = eventResponse.result` (即 `TaskStatusUpdateEvent` 或 `TaskArtifactUpdateEvent`)。
* **更新 React 状态:** 调用 `setStreamedContent(prev => [...prev, eventData])` 将新的事件数据添加到状态数组中,这将触发 UI 重新渲染。
* **检查结束标志:** 检查 `eventData.final === true`。如果为 `true`,则设置状态为 `completed` 并标记流结束。
7. **循环与退出:** `while` 循环会持续进行,直到 `reader.read()` 返回 `done: true`,或者内部处理(如解析错误、收到 `final: true`)决定中断。
**`useEffect` 处理最终报告:**
* 当 `status` 变为 `'completed'` 时,此 Hook 会运行。
* 它会反向遍历 `streamedContent` 数组,查找最后一个包含 `artifact` 的事件。
* 如果找到,则从中提取 `TextPart` 的内容并设置到 `finalReport` 状态,用于在页面底部单独展示完整报告。
## 状态管理
主要使用 `useState` 管理以下关键状态:
* `status`: `'idle' | 'streaming' | 'completed' | 'error' | 'aborted'` - UI 的宏观状态。
* `streamedContent`: `StreamEventResult[]` - 存储从 SSE 流接收并解析出的所有事件 `result` 对象。
* `error`: `string | null` - 存储发生的错误信息。
* `finalReport`: `string | null` - 存储从最终 Artifact 中提取的报告文本。
## 数据展示
* **流式内容输出:** 通过 `.map()` 遍历 `streamedContent` 数组。
* 根据每个 `eventData` 是 `TaskStatusUpdateEvent` 还是 `TaskArtifactUpdateEvent` 来决定显示标记("[状态更新]" 或 "[收到报告片段]")。
* 再遍历事件中的 `parts` 数组。
* 对 `TextPart`,直接显示 `part.text`。
* 对 `DataPart`,使用 `{JSON.stringify(part.data, null, 2)}` 格式化显示其 `data` 对象。**(优化点:可以根据 `data` 内部约定的字段进行更友好的渲染)**
* **最终报告:** 当 `finalReport` 有值时,在页面底部使用 `` 标签展示(可以替换为 Markdown 渲染器)。
## 限制与未来工作
* **UI 基础:** 当前 UI 非常简化,仅用于演示核心流式逻辑。需要构建更完善的组件、布局和样式。
* **仅流式:** 未包含发送同步任务 (`tasks/send`) 和轮询 (`tasks/get`) 的逻辑。
* **硬编码主题:** 研究主题是硬编码的,需要改为用户输入。
* **DataPart 展示:** 当前对 `DataPart` 只是简单显示 JSON,可以根据与后端约定的数据结构进行更丰富的可视化展示。
* **Markdown 渲染:** 最终报告目前使用 `` 显示,应替换为真正的 Markdown 渲染组件(如 `react-markdown`)。
* **错误处理:** 可以进一步细化错误处理和用户提示。
* **多轮对话/状态保持:** 当前实现不支持需要 Agent 保持状态的多轮对话。
* **真实推送通知:** 前端未处理 A2A 的推送通知逻辑。
## 后续步骤
1. **构建更丰富的 UI 组件:** 将输入、状态、进度、报告显示拆分成独立的、样式更美观的 React 组件。
2. **美化 `DataPart` 展示:** 根据你和后端约定好的 `DataPart` 结构,更有意义地展示结构化信息,而不是只显示 JSON。
3. **实现用户输入:** 将硬编码的研究主题替换为真正的用户输入。
4. **添加更完善的错误处理和用户反馈:** 例如,区分不同类型的错误,提供重试按钮等。
5. **管理 AbortController:** 确保在组件卸载或发起新请求时,之前的 `Workspace` 请求能被正确中止。
6. **状态管理库 (可选):** 如果应用变得复杂,可以引入 Zustand, Jotai, Redux 等状态管理库。
7. **添加同步任务逻辑:** 如果需要,可以添加调用 `tasks/send` 和轮询 `tasks/get` 的逻辑。
================================================
FILE: web_for_a2a/app/api/a2a/route.ts
================================================
// 文件路径: app/api/a2a/[[...slug]]/route.ts (适用于 App Router)
// 或 pages/api/a2a/[...slug].ts (适用于 Pages Router, 需 slight modification in handler signature)
import { type NextRequest, NextResponse } from 'next/server';
import { NextApiRequest, NextApiResponse } from 'next'; // For Pages Router
// 后端 A2A 服务器的地址
const A2A_BACKEND_URL = process.env.A2A_BACKEND_URL || 'http://127.0.0.1:8000';
// --- App Router Version ---
export async function POST(request: NextRequest) {
try {
// 1. 获取前端请求的 body
const body = await request.json();
console.log('[API Route] Forwarding POST request to:', A2A_BACKEND_URL);
console.log('[API Route] Request Body:', JSON.stringify(body, null, 2));
// 2. 构造转发到 A2A 后端的请求
// 注意: NextRequest.headers 是 Headers 对象, fetch 也接受 Headers 对象
// 我们需要筛选或传递合适的 Headers
const headersToForward = new Headers();
headersToForward.set('Content-Type', 'application/json');
// 如果后端需要 Accept 头来决定是否返回 SSE
if (body?.method === 'tasks/sendSubscribe') {
headersToForward.set('Accept', 'text/event-stream');
} else {
headersToForward.set('Accept', 'application/json');
}
// 你可能需要传递其他必要的头,例如 Authorization (如果需要的话)
// const authHeader = request.headers.get('Authorization');
// if (authHeader) headersToForward.set('Authorization', authHeader);
// 3. 使用 fetch 将请求转发到后端 A2A 服务器
const backendResponse = await fetch(A2A_BACKEND_URL, {
method: 'POST',
headers: headersToForward,
body: JSON.stringify(body),
// 重要:如果需要流式传输,Node fetch 需要 duplex:'half' (或者它默认支持流)
// 对于 Vercel Edge Runtime (默认在 App Router API Routes 中), fetch 原生支持流
// cache: 'no-store', // 确保不缓存
});
console.log(`[API Route] Backend response status: ${backendResponse.status}`);
backendResponse.headers.forEach((value, key) => console.log(`[API Route] Backend header: ${key}: ${value}`));
// 4. 处理后端响应
const contentType = backendResponse.headers.get('content-type');
if (contentType?.includes('text/event-stream') && backendResponse.body) {
// 4a. 如果是 SSE 流,将其转发给前端
console.log('[API Route] Forwarding SSE stream...');
// 创建一个新的 ReadableStream 将后端流转发给前端
const stream = new ReadableStream({
async start(controller) {
const reader = backendResponse.body!.getReader();
const decoder = new TextDecoder(); // 用于调试日志
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('[API Route] Backend stream ended.');
controller.close();
break;
}
const decodedChunk = decoder.decode(value); // 调试用
console.log('[API Route] Forwarding stream chunk:', decodedChunk.replace(/\n/g, '\\n'));
controller.enqueue(value); // 将原始 Uint8Array 块转发给前端
}
} catch (error) {
console.error('[API Route] Error reading from backend stream:', error);
controller.error(error);
} finally {
// 确保 reader 被释放 (尽管在 done=true 或 error 时通常会自动处理)
try {
reader.releaseLock();
} catch {}
}
}
});
// 返回带有正确 SSE 头信息的流式响应
return new Response(stream, {
status: backendResponse.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
// 可以选择性地转发其他必要的后端头信息
}
});
} else {
// 4b. 如果是普通 JSON 响应,解析并转发
console.log('[API Route] Forwarding JSON response...');
const jsonResponse = await backendResponse.json();
console.log('[API Route] Backend JSON:', jsonResponse);
return NextResponse.json(jsonResponse, { status: backendResponse.status });
}
} catch (error: any) {
console.error("[API Route] Error in proxy:", error);
return NextResponse.json(
{ error: 'Proxy error', detail: error.message },
{ status: 500 }
);
}
}
// 可以选择性地添加 GET 处理 /.well-known/agent.json (如果前端也想通过代理获取)
export async function GET(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/api/a2a/.well-known/agent.json') {
try {
const backendResponse = await fetch(`${A2A_BACKEND_URL}/.well-known/agent.json`);
if (!backendResponse.ok) { throw new Error(`Backend error: ${backendResponse.status}`)};
const data = await backendResponse.json();
return NextResponse.json(data);
} catch (error: any) {
console.error("[API Route] Error fetching agent card:", error);
return NextResponse.json({ error: 'Failed to fetch agent card'}, { status: 502 });
}
}
return NextResponse.json({ error: 'Not Found' }, { status: 404 });
}
// --- Pages Router Version (Alternative) ---
/*
import type { NextApiRequest, NextApiResponse } from 'next';
import httpProxyMiddleware from 'next-http-proxy-middleware'; // 需要安装 next-http-proxy-middleware
const A2A_BACKEND_URL = process.env.A2A_BACKEND_URL || 'http://127.0.0.1:8000';
export const config = {
api: {
// 关闭 Next.js 的默认 body 解析,让代理处理
bodyParser: false,
},
};
// 使用 next-http-proxy-middleware 处理代理 (更简单,但流式支持可能需要验证)
const handler = (req: NextApiRequest, res: NextApiResponse) => {
console.log(`[API Route Pages] Forwarding request ${req.method} ${req.url} to ${A2A_BACKEND_URL}`);
return httpProxyMiddleware(req, res, {
target: A2A_BACKEND_URL,
// 重写路径,移除 /api/a2a 前缀
pathRewrite: [{
patternStr: '^/api/a2a',
replaceStr: '',
}],
// 可能需要配置 changeOrigin: true
changeOrigin: true,
// selfHandleResponse: true, // 可能需要手动处理流式响应头,如果库不支持
// onProxyRes: (proxyRes, req, res) => {
// // 如果需要手动处理 SSE 头
// if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
// res.setHeader('Content-Type', 'text/event-stream');
// res.setHeader('Cache-Control', 'no-cache');
// res.setHeader('Connection', 'keep-alive');
// // 可能需要移除或修改其他头
// }
// }
});
};
export default handler;
*/
================================================
FILE: web_for_a2a/app/deepresearch/page.tsx
================================================
// 文件路径: mentis/web_for_a2a/app/deepresearch/page.tsx
'use client'; // 标记为客户端组件
import { useState, useCallback, useRef, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
// --- A2A 类型定义 (简化版) ---
interface TextPart { type: "text"; text: string; }
interface DataPart { type: "data"; data: Record; }
type Part = TextPart | DataPart;
interface Message { role: "user" | "agent"; parts: Part[]; }
// 使用字符串类型来匹配 TaskState 枚举值
type TaskStateString = "submitted" | "working" | "input-required" | "completed" | "canceled" | "failed" | "unknown";
interface TaskStatus { state: TaskStateString | string; message?: Message; } // 允许 string 以防万一
interface Artifact { parts: Part[]; index?: number; /* 其他可选字段 */ }
interface TaskStatusUpdateEvent { id: string; status: TaskStatus; final: boolean; }
interface TaskArtifactUpdateEvent { id:string; artifact: Artifact; final?: boolean; }
type StreamEventResult = TaskStatusUpdateEvent | TaskArtifactUpdateEvent;
interface JSONRPCError { code: number; message: string; data?: any; }
interface SendTaskStreamingResponse {
jsonrpc?: "2.0";
id?: string | number | null;
result?: StreamEventResult;
error?: JSONRPCError;
}
// --- 类型定义结束 ---
const A2A_SERVER_URL = process.env.NEXT_PUBLIC_A2A_SERVER_URL || 'http://127.0.0.1:8000';
export default function DeepResearchPage() {
// --- 状态管理 ---
const [status, setStatus] = useState<'idle' | 'streaming' | 'completed' | 'error' | 'aborted'>('idle');
const [streamedContent, setStreamedContent] = useState([]);
const [error, setError] = useState(null);
const [finalReport, setFinalReport] = useState(null);
const abortControllerRef = useRef(null);
// --- 清理函数 ---
useEffect(() => {
return () => {
console.log("组件卸载,中止进行中的 fetch 请求...");
abortControllerRef.current?.abort();
};
}, []);
// --- 核心:启动流式请求并处理 (保持不变) ---
const startStream = useCallback(async () => {
console.log("[startStream] Initiating stream...");
setStatus('streaming'); setError(null); setStreamedContent([]); setFinalReport(null);
if (abortControllerRef.current) { abortControllerRef.current.abort(); }
abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal;
const taskId = "webui_deep_research_" + uuidv4();
const research_topic = "特斯拉电动汽车的市场分析和未来发展趋势";
const message: Message = { role: "user", parts: [{ type: "text", text: research_topic }] };
const payload = { id: taskId, sessionId: "webui_session_" + uuidv4(), message: message, acceptedOutputModes: ["text"], metadata: { skill_name: "deep_research" } };
const requestBody = { jsonrpc: "2.0", method: "tasks/sendSubscribe", id: "req-" + taskId, params: payload };
try {
console.log("[startStream] Sending request:", JSON.stringify(requestBody, null, 2));
const response = await fetch(A2A_SERVER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, body: JSON.stringify(requestBody), signal: signal });
console.log(`[startStream] Initial response status: ${response.status}`);
console.log("[startStream] Received Response Headers:"); response.headers.forEach((value, key) => { console.log(` ${key}: ${value}`); });
const corsHeader = response.headers.get("access-control-allow-origin"); console.log(`[startStream] Access-Control-Allow-Origin Header value: ${corsHeader}`);
if (!response.ok) { let errorMsg = `HTTP error! status: ${response.status}`; try { const errJson = await response.json(); errorMsg = errJson?.error?.message || JSON.stringify(errJson); } catch { errorMsg = `${response.status} ${response.statusText}`; } throw new Error(errorMsg); }
const contentType = response.headers.get('content-type'); console.log(`[startStream] Initial response Content-Type: ${contentType}`);
if (!contentType || !contentType.includes('text/event-stream')) { let errorMsg = `Expected Content-Type 'text/event-stream', but got '${contentType}'`; try { const errBody = await response.text(); errorMsg += ` - Body: ${errBody}`; } catch {} throw new Error(errorMsg); }
const reader = response.body?.getReader(); if (!reader) throw new Error('Failed to get response body reader');
console.log("[startStream] Got reader, starting stream processing...");
await processStream(reader); // 调用修正后的 processStream
setStatus(prevStatus => { if (prevStatus === 'streaming') { console.log("[startStream] Stream processing finished without error/final flag, setting status to completed."); return 'completed'; } console.log("[startStream] Stream processing finished, keeping status:", prevStatus); return prevStatus; });
} catch (err: any) {
if (err.name === 'AbortError') { console.log('[startStream] Stream fetch aborted by client.'); setStatus(prevStatus => { if (prevStatus === 'streaming') { setError('请求已中止'); return 'aborted'; } return prevStatus; }); }
else { console.error("[startStream] Error during request setup or connection:", err); setError(`请求或连接失败: ${err.message}`); setStatus('error'); }
} finally { console.log("[startStream] Cleaning up AbortController."); abortControllerRef.current = null; }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- *** 核心修改:processStream 函数 *** ---
const processStream = async (reader: ReadableStreamDefaultReader) => {
const decoder = new TextDecoder();
let buffer = '';
let streamEndedInLoop = false;
console.log("[processStream] Starting stream processing loop.");
while (!streamEndedInLoop) {
try {
console.log("[processStream] Waiting for reader.read()...");
const { done, value } = await reader.read();
console.log(`[processStream] reader.read() returned: done=${done}, value size=${value?.length}`);
if (done) {
console.log("[processStream] Stream finished by reader (done=true).");
streamEndedInLoop = true;
break; // 显式跳出 while 循环
}
buffer += decoder.decode(value, { stream: true });
console.log(`[processStream] Decoded chunk, current buffer size: ${buffer.length}`); // 打印 buffer 大小
// --- 使用正则表达式分割 SSE 事件,更健壮 ---
// SSE 事件由两个换行符分隔 (\n\n, \r\r, or \r\n\r\n)
const eventSeparatorRegex = /\r\n\r\n|\n\n|\r\r/;
let match;
// 循环处理 buffer 中的所有完整事件
while ((match = eventSeparatorRegex.exec(buffer)) !== null) {
const boundaryIndex = match.index;
const eventString = buffer.substring(0, boundaryIndex); // 提取事件部分
buffer = buffer.substring(boundaryIndex + match[0].length); // 移除已处理的事件和分隔符
if (!eventString.trim()) {
console.log("[processStream] Skipping empty event string found by regex.");
continue; // 跳过空事件
}
console.log('[processStream] Processing raw SSE message:', eventString.replace(/\n/g, '\\n'));
// SSE 事件通常包含多行 (event:, id:, data:, retry:)
// 我们主要关心 data: 行
const lines = eventString.split(/\r\n|\n|\r/); // 按行分割
let eventType = 'message'; // 默认事件类型
let eventDataString = '';
let eventId = '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
// 如果 data 有多行,需要拼接
eventDataString += line.substring(5).trim() + "\n"; // 加换行符以区分多行 data
} else if (line.startsWith('id:')) {
eventId = line.substring(3).trim();
} // 可以添加对 retry: 的处理
}
eventDataString = eventDataString.trim(); // 移除末尾的换行符
// 只处理我们关心的包含有效数据的事件
if (eventDataString) {
console.log(`[processStream] Extracted SSE fields: type=${eventType}, id=${eventId}, data=${eventDataString}`);
try {
const eventResponse = JSON.parse(eventDataString) as SendTaskStreamingResponse;
console.log('[processStream] Successfully parsed JSON:', eventResponse);
if (eventResponse.error) {
const error = eventResponse.error; console.error("[processStream] Received SSE Error from server:", error);
setError(`流式错误 (来自服务器): Code=${error.code}, Msg=${error.message}`); setStatus('error');
streamEndedInLoop = true; break; // Exit inner processing loop
}
const eventData = eventResponse.result;
if (eventData) {
console.log("[processStream] Preparing to call setStreamedContent with:", eventData);
setStreamedContent(prev => [...prev, eventData]); // Update state
console.log("[processStream] Call to setStreamedContent completed.");
if (eventData.final === true) {
console.log("[processStream] Final event flag received. Setting status to completed.");
streamEndedInLoop = true; setStatus('completed');
// Let the inner loop finish processing this chunk, outer loop will break
} else {
setStatus(prevStatus => (prevStatus !== 'completed' && prevStatus !== 'error' && prevStatus !== 'aborted') ? 'streaming' : prevStatus);
}
} else { console.log("[processStream] Skipping event with no result data."); }
} catch (e: any) {
console.error("[processStream] Failed to parse SSE JSON data:", e, "\nRaw JSON string was:", eventDataString);
setError(`解析服务器事件失败: ${e.message}. 收到的数据 (部分): ${eventDataString.substring(0, 150)}...`); setStatus('error');
streamEndedInLoop = true; break; // Exit inner processing loop
}
} else {
console.log("[processStream] Skipping SSE message with no data field.");
}
if (streamEndedInLoop) break; // Exit inner processing loop if needed
} // end while match = regex.exec(buffer)
if(streamEndedInLoop) break; // Exit outer while if needed
} catch (readError: any) {
console.error("[processStream] Error reading from stream:", readError);
if (readError.name !== 'AbortError') { setError(`读取流错误: ${readError.message}`); setStatus('error'); }
else { console.log("[processStream] Stream reading aborted by client."); setStatus('aborted'); }
streamEndedInLoop = true; break; // Exit outer while
}
} // end while (!streamEndedInLoop)
console.log("[processStream] Exited stream processing loop.");
}; // end processStream
// --- useEffect 处理最终报告 (保持不变) ---
useEffect(() => {
if (status === 'completed' && streamedContent.length > 0) {
console.log("[useEffect] Status is completed, processing final report from streamedContent.");
const finalArtifactEvent = [...streamedContent].reverse().find(ev => ev && 'artifact' in ev) as TaskArtifactUpdateEvent | undefined;
if (finalArtifactEvent?.artifact?.parts) {
const reportPart = finalArtifactEvent.artifact.parts.find(p => p.type === 'text') as TextPart | undefined;
if (reportPart) { console.log("[useEffect] Found final report text in artifact."); setFinalReport(reportPart.text); }
else { console.log("[useEffect] Completed, but no text part found in final artifact event."); }
} else {
console.log("[useEffect] Completed, but no artifact event found or artifact has no parts.");
const lastStatusEvent = [...streamedContent].reverse().find(ev => ev && 'status' in ev) as TaskStatusUpdateEvent | undefined;
if (lastStatusEvent?.status?.message?.parts) {
const reportPart = lastStatusEvent.status.message.parts.find(p => p.type === 'text') as TextPart | undefined;
if (reportPart) { console.warn("[useEffect] No artifact found, using text from last status update as final report (fallback)."); setFinalReport(reportPart.text); }
}
}
}
}, [status, streamedContent]);
// --- UI 渲染 (保持不变) ---
return (
{/* ... (JSX 代码同上一版本) ... */}
DeepResearch A2A 流式客户端 (带调试日志 v2)
当前状态: {status}
{error &&
错误: {error}
}
流式内容输出:
{streamedContent.length === 0 && status !== 'streaming' && status !== 'error' && status !== 'aborted' &&
尚未接收到流式内容。
}
{streamedContent.map((eventData, index) => {
let displayContent: React.ReactNode = null; let parts: Part[] | undefined = undefined;
if (eventData && 'status' in eventData && eventData.status?.message?.parts) { parts = eventData.status.message.parts; displayContent =
[状态更新]; }
else if (eventData && 'artifact' in eventData && eventData.artifact?.parts) { parts = eventData.artifact.parts; displayContent =
[收到报告片段]; }
if (parts) { displayContent = (<>{displayContent}{" "}{parts.map((part, pIdx) => { if (part.type === 'text') {return
{part.text};} else if (part.type === 'data') {return
{JSON.stringify(part.data, null, 2)};} return null; })}>); }
else if (typeof eventData === 'object' && eventData !== null) { displayContent =
{JSON.stringify(eventData, null, 2)}; }
else { displayContent =
未知事件: {String(eventData)};}
return
{displayContent}
;
})}
{status === 'streaming' &&
等待服务器事件...
}
{status === 'completed' && !finalReport &&
流处理完成,但未找到最终报告 Artifact。
}
{status === 'error' &&
流处理因错误终止。
}
{status === 'aborted' &&
流处理已中止。
}
{finalReport && (
<>
最终报告:
{status === 'completed' &&
任务已成功完成。
}
>
)}
);
}
================================================
FILE: web_for_a2a/app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}
.prose {
max-width: 65ch;
color: inherit;
}
.prose pre {
background-color: #f3f4f6;
border-radius: 0.375rem;
padding: 0.75rem;
overflow-x: auto;
}
================================================
FILE: web_for_a2a/app/layout.tsx
================================================
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'DeepResearch A2A Web Client',
description: '基于Next.js的DeepResearch A2A流式客户端',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
================================================
FILE: web_for_a2a/app/page.tsx
================================================
'use client';
import Link from 'next/link';
export default function Home() {
return (
DeepResearch A2A Web 客户端
功能介绍
这是一个基于 Next.js 和 React 构建的 Web 客户端,用于连接 DeepResearch A2A 服务器并展示流式研究结果。
通过 Server-Sent Events (SSE) 技术,可以实时接收和显示研究进度和最终报告。
本示例演示了如何从前端 Web 应用连接到 DeepResearch A2A 服务器 (tasks/sendSubscribe 端点),
并接收、解析、显示 SSE 流。
使用前提
-
确保
super_agents/deep_research/a2a_adapter/run_server.py 启动的服务器正在运行在
http://127.0.0.1:8000 (或相应的地址)。
-
当前示例使用硬编码的研究主题 "特斯拉电动汽车的市场分析和未来发展趋势"。
进入 DeepResearch 示例页面
);
}
================================================
FILE: web_for_a2a/package.json
================================================
{
"name": "web_for_a2a",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1",
"typescript": "^5.2.2",
"@types/node": "^20.8.9",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5"
}
}
================================================
FILE: web_for_a2a/postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: web_for_a2a/tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};
================================================
FILE: web_for_a2a/tsconfig.json
================================================
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}